chore: cleanup unused code and dead imports

This commit is contained in:
zarzet committed 2026-01-20 02:10:10 +07:00
1 parent 8e9d0c3e9a
commit 03027813c1
62 files changed
+213 -1326

No files matched your search

+4 -21
View File
@@ -17,13 +17,12 @@ import (
"time" "time"
) )
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
type AmazonDownloader struct { type AmazonDownloader struct {
client *http.Client client *http.Client
regions []string // us, eu regions for DoubleDouble service regions []string
lastAPICallTime time.Time // Rate limiting: track last API call lastAPICallTime time.Time
apiCallCount int // Rate limiting: counter per minute apiCallCount int
apiCallResetTime time.Time // Rate limiting: reset time apiCallResetTime time.Time
} }
var ( var (
@@ -38,7 +37,6 @@ type DoubleDoubleSubmitResponse struct {
ID string `json:"id"` ID string `json:"id"`
} }
// DoubleDoubleStatusResponse is the response from DoubleDouble status endpoint
type DoubleDoubleStatusResponse struct { type DoubleDoubleStatusResponse struct {
Status string `json:"status"` Status string `json:"status"`
FriendlyStatus string `json:"friendlyStatus"` FriendlyStatus string `json:"friendlyStatus"`
@@ -49,7 +47,6 @@ type DoubleDoubleStatusResponse struct {
} `json:"current"` } `json:"current"`
} }
// amazonArtistsMatch checks if the artist names are similar enough
func amazonArtistsMatch(expectedArtist, foundArtist string) bool { func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist))
@@ -90,7 +87,6 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
return false return false
} }
// amazonIsASCIIString checks if a string contains only ASCII characters
func amazonIsASCIIString(s string) bool { func amazonIsASCIIString(s string) bool {
for _, r := range s { for _, r := range s {
if r > 127 { if r > 127 {
@@ -100,7 +96,6 @@ func amazonIsASCIIString(s string) bool {
return true return true
} }
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
func NewAmazonDownloader() *AmazonDownloader { func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() { amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{ globalAmazonDownloader = &AmazonDownloader{
@@ -113,7 +108,6 @@ func NewAmazonDownloader() *AmazonDownloader {
} }
// waitForRateLimit implements rate limiting similar to PC version // waitForRateLimit implements rate limiting similar to PC version
// Max 9 requests per minute with 7 second delay between requests
func (a *AmazonDownloader) waitForRateLimit() { func (a *AmazonDownloader) waitForRateLimit() {
amazonRateLimitMu.Lock() amazonRateLimitMu.Lock()
defer amazonRateLimitMu.Unlock() defer amazonRateLimitMu.Unlock()
@@ -125,7 +119,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
a.apiCallResetTime = now a.apiCallResetTime = now
} }
// If we've hit the limit (9 requests per minute), wait until next minute
if a.apiCallCount >= 9 { if a.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(a.apiCallResetTime) waitTime := time.Minute - now.Sub(a.apiCallResetTime)
if waitTime > 0 { if waitTime > 0 {
@@ -136,7 +129,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
} }
} }
// Add delay between requests (7 seconds like PC version)
if !a.lastAPICallTime.IsZero() { if !a.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(a.lastAPICallTime) timeSinceLastCall := now.Sub(a.lastAPICallTime)
minDelay := 7 * time.Second minDelay := 7 * time.Second
@@ -151,7 +143,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
a.apiCallCount++ a.apiCallCount++
} }
// GetAvailableAPIs returns list of available DoubleDouble regions
// Uses same service as PC version (doubledouble.top) // Uses same service as PC version (doubledouble.top)
func (a *AmazonDownloader) GetAvailableAPIs() []string { func (a *AmazonDownloader) GetAvailableAPIs() []string {
// DoubleDouble service regions (same as PC) // DoubleDouble service regions (same as PC)
@@ -176,11 +167,9 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain)) baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
// Step 1: Submit download request with rate limiting
encodedURL := url.QueryEscape(amazonURL) encodedURL := url.QueryEscape(amazonURL)
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL) submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
// Apply rate limiting before request (like PC version)
a.waitForRateLimit() a.waitForRateLimit()
req, err := http.NewRequest("GET", submitURL, nil) req, err := http.NewRequest("GET", submitURL, nil)
@@ -334,7 +323,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError) return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
} }
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background() ctx := context.Background()
@@ -434,7 +422,6 @@ type AmazonDownloadResult struct {
ISRC string ISRC string
} }
// downloadFromAmazon downloads a track using the request parameters
// Uses DoubleDouble service (same as PC version) // Uses DoubleDouble service (same as PC version)
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader() downloader := NewAmazonDownloader()
@@ -580,15 +567,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
fmt.Printf("Warning: failed to embed metadata: %v\n", err) fmt.Printf("Warning: failed to embed metadata: %v\n", err)
} }
// Handle lyrics based on LyricsMode setting
// Mode: "embed" (default), "external" (.lrc file), "both"
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" // default
} }
// Save external .lrc file if mode is "external" or "both"
if lyricsMode == "external" || lyricsMode == "both" { if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Amazon] Saving external LRC file...\n") GoLog("[Amazon] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
@@ -598,7 +582,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
} }
} }
// Embed lyrics if mode is "embed" or "both"
if lyricsMode == "embed" || lyricsMode == "both" { if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
+3 -14
View File
@@ -8,18 +8,15 @@ import (
"strings" "strings"
) )
// Spotify image size codes (same as PC version)
const ( const (
spotifySize300 = "ab67616d00001e02" // 300x300 (small) spotifySize300 = "ab67616d00001e02"
spotifySize640 = "ab67616d0000b273" // 640x640 (medium) spotifySize640 = "ab67616d0000b273"
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000) spotifySizeMax = "ab67616d000082c1"
) )
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800 // Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`) var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
// Same logic as PC version for consistency
func convertSmallToMedium(imageURL string) string { func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) { if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1) return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
@@ -27,8 +24,6 @@ func convertSmallToMedium(imageURL string) string {
return imageURL return imageURL
} }
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
// This avoids file permission issues on Android
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
if coverURL == "" { if coverURL == "" {
return nil, fmt.Errorf("no cover URL provided") return nil, fmt.Errorf("no cover URL provided")
@@ -90,8 +85,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return data, nil return data, nil
} }
// upgradeToMaxQuality upgrades cover URL to maximum quality
// Supports both Spotify and Deezer CDNs
func upgradeToMaxQuality(coverURL string) string { func upgradeToMaxQuality(coverURL string) string {
// Spotify CDN upgrade // Spotify CDN upgrade
if strings.Contains(coverURL, spotifySize640) { if strings.Contains(coverURL, spotifySize640) {
@@ -106,9 +99,6 @@ func upgradeToMaxQuality(coverURL string) string {
return coverURL return coverURL
} }
// upgradeDeezerCover upgrades Deezer cover URL to maximum quality (1800x1800)
// Deezer CDN format: https://cdn-images.dzcdn.net/images/cover/{hash}/{size}x{size}-000000-80-0-0.jpg
// Available sizes: 56, 250, 500, 1000, 1400, 1800
func upgradeDeezerCover(coverURL string) string { func upgradeDeezerCover(coverURL string) string {
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") { if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return coverURL return coverURL
@@ -122,7 +112,6 @@ func upgradeDeezerCover(coverURL string) string {
return upgraded return upgraded
} }
// GetCoverFromSpotify gets cover URL from Spotify metadata
func GetCoverFromSpotify(imageURL string, maxQuality bool) string { func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" { if imageURL == "" {
return "" return ""
+6 -34
View File
@@ -25,13 +25,12 @@ const (
deezerMaxParallelISRC = 10 deezerMaxParallelISRC = 10
) )
// DeezerClient handles Deezer API interactions (no auth required)
type DeezerClient struct { type DeezerClient struct {
httpClient *http.Client httpClient *http.Client
searchCache map[string]*cacheEntry searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry albumCache map[string]*cacheEntry
artistCache map[string]*cacheEntry artistCache map[string]*cacheEntry
isrcCache map[string]string // trackID -> ISRC cache isrcCache map[string]string
cacheMu sync.RWMutex cacheMu sync.RWMutex
} }
@@ -40,7 +39,6 @@ var (
deezerClientOnce sync.Once deezerClientOnce sync.Once
) )
// GetDeezerClient returns singleton Deezer client
func GetDeezerClient() *DeezerClient { func GetDeezerClient() *DeezerClient {
deezerClientOnce.Do(func() { deezerClientOnce.Do(func() {
deezerClient = &DeezerClient{ deezerClient = &DeezerClient{
@@ -54,7 +52,6 @@ func GetDeezerClient() *DeezerClient {
return deezerClient return deezerClient
} }
// Deezer API response types
type deezerTrack struct { type deezerTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
@@ -63,7 +60,7 @@ type deezerTrack struct {
DiskNumber int `json:"disk_number"` DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
Link string `json:"link"` Link string `json:"link"`
ReleaseDate string `json:"release_date"` // Sometimes at track level ReleaseDate string `json:"release_date"`
Artist deezerArtist `json:"artist"` Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"` Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"` Contributors []deezerArtist `json:"contributors"`
@@ -86,8 +83,8 @@ type deezerAlbumSimple struct {
CoverMedium string `json:"cover_medium"` CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"` CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"` CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` // Sometimes at album level ReleaseDate string `json:"release_date"`
RecordType string `json:"record_type"` // album, single, ep, compile RecordType string `json:"record_type"`
} }
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
@@ -146,8 +143,8 @@ type deezerAlbumFull struct {
CoverXL string `json:"cover_xl"` CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"` NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"` // album, single, ep, compile RecordType string `json:"record_type"`
Label string `json:"label"` // Record label name Label string `json:"label"`
Genres struct { Genres struct {
Data []deezerGenre `json:"data"` Data []deezerGenre `json:"data"`
} `json:"genres"` } `json:"genres"`
@@ -185,7 +182,6 @@ type deezerPlaylistFull struct {
} `json:"tracks"` } `json:"tracks"`
} }
// SearchAll searches for tracks and artists on Deezer
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download // NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit) GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
@@ -230,11 +226,9 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data)) GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
for _, track := range trackResp.Data { for _, track := range trackResp.Data {
// Convert directly without fetching ISRC - much faster
result.Tracks = append(result.Tracks, c.convertTrack(track)) result.Tracks = append(result.Tracks, c.convertTrack(track))
} }
// Search artists
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit) artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
GoLog("[Deezer] Fetching artists from: %s\n", artistURL) GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
@@ -267,7 +261,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists)) GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
// Cache result
c.cacheMu.Lock() c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{ c.searchCache[cacheKey] = &cacheEntry{
data: result, data: result,
@@ -292,7 +285,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
}, nil }, nil
} }
// GetAlbum fetches album with tracks
// ISRC is fetched in parallel for better performance // 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()
@@ -338,7 +330,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
Label: album.Label, // From Deezer album Label: album.Label, // From Deezer album
} }
// Fetch ISRCs in parallel
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data) isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data)) tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
@@ -386,7 +377,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
return result, nil return result, nil
} }
// GetArtist fetches artist with albums
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) { func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() { if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
@@ -472,8 +462,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
return result, nil return result, nil
} }
// GetPlaylist fetches playlist with tracks
// ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) { func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID) playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
@@ -496,7 +484,6 @@ 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 ISRCs in parallel
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data) isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data)) tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
@@ -535,15 +522,11 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
}, nil }, nil
} }
// SearchByISRC searches for a track by ISRC using direct endpoint
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) { func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
// Use direct ISRC endpoint (API 2.0)
// https://api.deezer.com/2.0/track/isrc:{ISRC}
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc) directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
var track deezerTrack var track deezerTrack
if err := c.getJSON(ctx, directURL, &track); err != nil { if err := c.getJSON(ctx, directURL, &track); err != nil {
// Fallback to search if direct endpoint fails
searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc) searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
var resp struct { var resp struct {
Data []deezerTrack `json:"data"` Data []deezerTrack `json:"data"`
@@ -623,7 +606,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
go func(t deezerTrack) { go func(t deezerTrack) {
defer wg.Done() defer wg.Done()
// Acquire semaphore
select { select {
case sem <- struct{}{}: case sem <- struct{}{}:
defer func() { <-sem }() defer func() { <-sem }()
@@ -652,7 +634,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
return result return result
} }
// GetTrackISRC fetches ISRC for a single track (with caching)
// Use this when you need ISRC for download // 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()
@@ -662,13 +643,11 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
// Fetch from API
fullTrack, err := c.fetchFullTrack(ctx, trackID) fullTrack, err := c.fetchFullTrack(ctx, trackID)
if err != nil { if err != nil {
return "", err return "", err
} }
// Cache the result
c.cacheMu.Lock() c.cacheMu.Lock()
c.isrcCache[trackID] = fullTrack.ISRC c.isrcCache[trackID] = fullTrack.ISRC
c.cacheMu.Unlock() c.cacheMu.Unlock()
@@ -715,20 +694,17 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
return album.Cover return album.Cover
} }
// AlbumExtendedMetadata contains genre and label information from an album
type AlbumExtendedMetadata struct { type AlbumExtendedMetadata struct {
Genre string // Comma-separated list of genres Genre string // Comma-separated list of genres
Label string // Record label name Label string // Record label name
} }
// GetAlbumExtendedMetadata fetches genre and label from a Deezer album
// Uses the album ID from a track to fetch extended metadata // 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")
} }
// Check cache first
cacheKey := fmt.Sprintf("album_meta:%s", albumID) cacheKey := fmt.Sprintf("album_meta:%s", albumID)
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
@@ -744,7 +720,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
return nil, fmt.Errorf("failed to fetch album: %w", err) return nil, fmt.Errorf("failed to fetch album: %w", err)
} }
// 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 != "" {
@@ -757,7 +732,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
Label: album.Label, Label: album.Label,
} }
// Cache the result
c.cacheMu.Lock() c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{ c.searchCache[cacheKey] = &cacheEntry{
data: result, data: result,
@@ -782,7 +756,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
} }
// GetExtendedMetadataByTrackID fetches genre and label using a Deezer track ID
// This is a convenience function that first gets the album ID, then fetches album metadata // 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)
@@ -837,7 +810,6 @@ func parseDeezerURL(input string) (string, string, error) {
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
// Skip language prefix if present (e.g., /en/, /fr/)
if len(parts) > 0 && len(parts[0]) == 2 { if len(parts) > 0 && len(parts[0]) == 2 {
parts = parts[1:] parts = parts[1:]
} }
-6
View File
@@ -158,7 +158,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
return "", false return "", false
} }
// Use index for fast lookup
idx := GetISRCIndex(outputDir) idx := GetISRCIndex(outputDir)
filePath, exists := idx.lookup(isrc) filePath, exists := idx.lookup(isrc)
if !exists { if !exists {
@@ -175,7 +174,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
} }
// CheckISRCExists is the exported version for gomobile (returns string, error) // CheckISRCExists is the exported version for gomobile (returns string, error)
// Returns the filepath if exists, empty string if not
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
@@ -199,9 +197,6 @@ type FileExistenceResult struct {
ArtistName string `json:"artist_name,omitempty"` ArtistName string `json:"artist_name,omitempty"`
} }
// CheckFilesExistParallel checks if multiple files exist in parallel
// It builds an ISRC index from the output directory once, then checks all tracks against it
// Same implementation as PC version for consistency
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) { func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
var tracks []struct { var tracks []struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
@@ -266,7 +261,6 @@ func PreBuildISRCIndex(outputDir string) error {
} }
// AddToISRCIndex adds a new file to the ISRC index after successful download // AddToISRCIndex adds a new file to the ISRC index after successful download
// This avoids rebuilding the entire index
func AddToISRCIndex(outputDir, isrc, filePath string) { func AddToISRCIndex(outputDir, isrc, filePath string) {
if outputDir == "" || isrc == "" || filePath == "" { if outputDir == "" || isrc == "" || filePath == "" {
return return
+33 -124
View File
@@ -13,8 +13,6 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
// ParseSpotifyURL parses and validates a Spotify URL
// Returns JSON with type (track/album/playlist) and ID
func ParseSpotifyURL(url string) (string, error) { func ParseSpotifyURL(url string) (string, error) {
parsed, err := parseSpotifyURI(url) parsed, err := parseSpotifyURI(url)
if err != nil { if err != nil {
@@ -34,19 +32,14 @@ func ParseSpotifyURL(url string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
func SetSpotifyAPICredentials(clientID, clientSecret string) { func SetSpotifyAPICredentials(clientID, clientSecret string) {
SetSpotifyCredentials(clientID, clientSecret) SetSpotifyCredentials(clientID, clientSecret)
} }
// CheckSpotifyCredentials checks if Spotify credentials are configured
// Returns true if credentials are available (custom or env vars)
func CheckSpotifyCredentials() bool { func CheckSpotifyCredentials() bool {
return HasSpotifyCredentials() return HasSpotifyCredentials()
} }
// GetSpotifyMetadata fetches metadata from Spotify URL
// Returns JSON with track/album/playlist data
func GetSpotifyMetadata(spotifyURL string) (string, error) { func GetSpotifyMetadata(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@@ -68,8 +61,6 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SearchSpotify searches for tracks on Spotify
// Returns JSON array of track results
func SearchSpotify(query string, limit int) (string, error) { func SearchSpotify(query string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
@@ -91,8 +82,6 @@ func SearchSpotify(query string, limit int) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SearchSpotifyAll searches for tracks and artists on Spotify
// Returns JSON with tracks and artists arrays
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) { func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
@@ -114,8 +103,6 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// CheckAvailability checks track availability on streaming services
// Returns JSON with availability info for Tidal, Qobuz, Amazon
func CheckAvailability(spotifyID, isrc string) (string, error) { func CheckAvailability(spotifyID, isrc string) (string, error) {
client := NewSongLinkClient() client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc) availability, err := client.CheckTrackAvailability(spotifyID, isrc)
@@ -131,7 +118,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// DownloadRequest represents a download request from Flutter
type DownloadRequest struct { type DownloadRequest struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
Service string `json:"service"` Service string `json:"service"`
@@ -143,58 +129,51 @@ type DownloadRequest struct {
CoverURL string `json:"cover_url"` CoverURL string `json:"cover_url"`
OutputDir string `json:"output_dir"` OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"` FilenameFormat string `json:"filename_format"`
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS Quality string `json:"quality"`
EmbedLyrics bool `json:"embed_lyrics"` EmbedLyrics bool `json:"embed_lyrics"`
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
TrackNumber int `json:"track_number"` TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"` DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"` TotalTracks int `json:"total_tracks"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
ItemID string `json:"item_id"` // Unique ID for progress tracking ItemID string `json:"item_id"`
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification) DurationMS int `json:"duration_ms"`
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension) Source string `json:"source"`
// Extended metadata from Deezer for FLAC tagging Genre string `json:"genre,omitempty"`
Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated Label string `json:"label,omitempty"`
Label string `json:"label,omitempty"` // Record label name Copyright string `json:"copyright,omitempty"`
Copyright string `json:"copyright,omitempty"` // Copyright information TidalID string `json:"tidal_id,omitempty"`
// Enriched IDs from Odesli/song.link - used to skip search and directly fetch QobuzID string `json:"qobuz_id,omitempty"`
TidalID string `json:"tidal_id,omitempty"` DeezerID string `json:"deezer_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"` LyricsMode string `json:"lyrics_mode,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
// Lyrics mode: "embed" (default), "external" (.lrc file), "both"
LyricsMode string `json:"lyrics_mode,omitempty"`
} }
// DownloadResponse represents the result of a download // 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"` // "not_found", "rate_limit", "network", "unknown"
AlreadyExists bool `json:"already_exists,omitempty"` AlreadyExists bool `json:"already_exists,omitempty"`
// Actual quality info from the source 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"` // Actual service used (for fallback) 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"` AlbumArtist string `json:"album_artist,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"` ReleaseDate string `json:"release_date,omitempty"`
ReleaseDate string `json:"release_date,omitempty"` TrackNumber int `json:"track_number,omitempty"`
TrackNumber int `json:"track_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"` ISRC string `json:"isrc,omitempty"`
ISRC string `json:"isrc,omitempty"` CoverURL string `json:"cover_url,omitempty"`
CoverURL string `json:"cover_url,omitempty"` Genre string `json:"genre,omitempty"`
// Extended metadata for FLAC tagging (passed to Flutter for M4A->FLAC conversion) Label string `json:"label,omitempty"`
Genre string `json:"genre,omitempty"` // Music genre(s) Copyright string `json:"copyright,omitempty"`
Label string `json:"label,omitempty"` // Record label SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
Copyright string `json:"copyright,omitempty"` // Copyright info
// If true, skip metadata enrichment from Deezer/Spotify (extension already provides metadata)
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
} }
// DownloadResult is a generic result type for all downloaders
type DownloadResult struct { type DownloadResult struct {
FilePath string FilePath string
BitDepth int BitDepth int
@@ -208,9 +187,6 @@ type DownloadResult struct {
ISRC string ISRC string
} }
// DownloadTrack downloads a track from the specified service
// requestJSON is a JSON string of DownloadRequest
// Returns JSON string of DownloadResponse
func DownloadTrack(requestJSON string) (string, error) { func DownloadTrack(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 {
@@ -224,7 +200,6 @@ func DownloadTrack(requestJSON string) (string, error) {
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir) req.OutputDir = strings.TrimSpace(req.OutputDir)
// Add output directory to allowed download dirs for extensions
if req.OutputDir != "" { if req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir) AddAllowedDownloadDir(req.OutputDir)
} }
@@ -348,22 +323,18 @@ func DownloadTrack(requestJSON string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// DownloadWithFallback tries to download from services in order
// Starts with the preferred service from request, then tries others
func DownloadWithFallback(requestJSON string) (string, error) { func DownloadWithFallback(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 {
return errorResponse("Invalid request: " + err.Error()) return errorResponse("Invalid request: " + err.Error())
} }
// Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName) req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName) req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName) req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir) req.OutputDir = strings.TrimSpace(req.OutputDir)
// Add output directory to allowed download dirs for extensions
if req.OutputDir != "" { if req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir) AddAllowedDownloadDir(req.OutputDir)
} }
@@ -520,47 +491,36 @@ func DownloadWithFallback(requestJSON string) (string, error) {
return errorResponse("All services failed. Last error: " + lastErr.Error()) return errorResponse("All services failed. Last error: " + lastErr.Error())
} }
// GetDownloadProgress returns current download progress
func GetDownloadProgress() string { func GetDownloadProgress() string {
progress := getProgress() progress := getProgress()
jsonBytes, _ := json.Marshal(progress) jsonBytes, _ := json.Marshal(progress)
return string(jsonBytes) return string(jsonBytes)
} }
// GetAllDownloadProgress returns progress for all active downloads (concurrent mode)
func GetAllDownloadProgress() string { func GetAllDownloadProgress() string {
return GetMultiProgress() return GetMultiProgress()
} }
// InitItemProgress initializes progress tracking for a download item
func InitItemProgress(itemID string) { func InitItemProgress(itemID string) {
StartItemProgress(itemID) StartItemProgress(itemID)
} }
// FinishItemProgress marks a download item as complete and removes tracking
func FinishItemProgress(itemID string) { func FinishItemProgress(itemID string) {
CompleteItemProgress(itemID) CompleteItemProgress(itemID)
} }
// ClearItemProgress removes progress tracking for a specific item
func ClearItemProgress(itemID string) { func ClearItemProgress(itemID string) {
RemoveItemProgress(itemID) RemoveItemProgress(itemID)
} }
// CancelDownload cancels an in-progress download for the given item.
func CancelDownload(itemID string) { func CancelDownload(itemID string) {
cancelDownload(itemID) cancelDownload(itemID)
} }
// CleanupConnections closes idle HTTP connections
// Call this periodically during large batch downloads to prevent TCP exhaustion
func CleanupConnections() { func CleanupConnections() {
CloseIdleConnections() CloseIdleConnections()
} }
// ReadFileMetadata reads metadata directly from a FLAC file
// Returns JSON with all embedded metadata (title, artist, album, track number, etc.)
// This is useful for displaying accurate metadata in the UI without relying on cached data
func ReadFileMetadata(filePath string) (string, error) { func ReadFileMetadata(filePath string) (string, error) {
metadata, err := ReadMetadata(filePath) metadata, err := ReadMetadata(filePath)
if err != nil { if err != nil {
@@ -600,12 +560,10 @@ func ReadFileMetadata(filePath string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// SetDownloadDirectory sets the default download directory
func SetDownloadDirectory(path string) error { func SetDownloadDirectory(path string) error {
return setDownloadDir(path) return setDownloadDir(path)
} }
// CheckDuplicate checks if a file with the given ISRC exists
func CheckDuplicate(outputDir, isrc string) (string, error) { func CheckDuplicate(outputDir, isrc string) (string, error) {
existingFile, exists := CheckISRCExists(outputDir, isrc) existingFile, exists := CheckISRCExists(outputDir, isrc)
@@ -622,26 +580,18 @@ func CheckDuplicate(outputDir, isrc string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// CheckDuplicatesBatch checks multiple files for duplicates in parallel
// Uses ISRC index for fast lookup (builds index once, checks all tracks)
// tracksJSON format: [{"isrc": "...", "track_name": "...", "artist_name": "..."}, ...]
// Returns JSON array of results
func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) { func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) {
return CheckFilesExistParallel(outputDir, tracksJSON) return CheckFilesExistParallel(outputDir, tracksJSON)
} }
// PreBuildDuplicateIndex pre-builds the ISRC index for a directory
// Call this when entering album/playlist screen for faster duplicate checking
func PreBuildDuplicateIndex(outputDir string) error { func PreBuildDuplicateIndex(outputDir string) error {
return PreBuildISRCIndex(outputDir) return PreBuildISRCIndex(outputDir)
} }
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
func InvalidateDuplicateIndex(outputDir string) { func InvalidateDuplicateIndex(outputDir string) {
InvalidateISRCCache(outputDir) InvalidateISRCCache(outputDir)
} }
// BuildFilename builds a filename from template and metadata
func BuildFilename(template string, metadataJSON string) (string, error) { func BuildFilename(template string, metadataJSON string) (string, error) {
var metadata map[string]interface{} var metadata map[string]interface{}
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
@@ -652,14 +602,10 @@ func BuildFilename(template string, metadataJSON string) (string, error) {
return filename, nil return filename, nil
} }
// SanitizeFilename removes invalid characters from filename
func SanitizeFilename(filename string) string { func SanitizeFilename(filename string) string {
return sanitizeFilename(filename) return sanitizeFilename(filename)
} }
// FetchLyrics fetches lyrics for a track from LRCLIB
// Returns JSON with lyrics data
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) { func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
client := NewLyricsClient() client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0 durationSec := float64(durationMs) / 1000.0
@@ -683,9 +629,6 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
// First tries to extract from file, then falls back to fetching from internet
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
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 != "" { if filePath != "" {
lyrics, err := ExtractLyrics(filePath) lyrics, err := ExtractLyrics(filePath)
@@ -705,7 +648,6 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
return lrcContent, nil return lrcContent, nil
} }
// EmbedLyricsToFile embeds lyrics into an existing FLAC file
func EmbedLyricsToFile(filePath, lyrics string) (string, error) { func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
err := EmbedLyrics(filePath, lyrics) err := EmbedLyrics(filePath, lyrics)
if err != nil { if err != nil {
@@ -721,9 +663,6 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// PreWarmTrackCacheJSON pre-warms the track ID cache for album/playlist tracks
// tracksJSON is a JSON array of objects with: isrc, track_name, artist_name, spotify_id, service
// This runs in background and returns immediately
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) { func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
var tracks []struct { var tracks []struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
@@ -759,20 +698,14 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// GetTrackCacheSize returns the current track ID cache size
func GetTrackCacheSize() int { func GetTrackCacheSize() int {
return GetCacheSize() return GetCacheSize()
} }
// ClearTrackIDCache clears the track ID cache
func ClearTrackIDCache() { func ClearTrackIDCache() {
ClearTrackCache() ClearTrackCache()
} }
// ==================== DEEZER API ====================
// SearchDeezerAll searches for tracks and artists on Deezer (no API key required)
// Returns JSON with tracks and artists arrays
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) { func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
@@ -990,10 +923,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API") return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
} }
// ==================== SONGLINK DEEZER SUPPORT ====================
// CheckAvailabilityFromDeezerID checks track availability using Deezer track ID as source
// Returns JSON with availability info for Spotify, Tidal, Amazon, etc.
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) { func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
client := NewSongLinkClient() client := NewSongLinkClient()
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID) availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
@@ -1177,14 +1106,12 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
return "", err return "", err
} }
// Initialize with saved settings
settingsStore := GetExtensionSettingsStore() settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID) settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 { if len(settings) > 0 {
manager.InitializeExtension(ext.ID, settings) manager.InitializeExtension(ext.ID, settings)
} }
// Return extension info as JSON
result := map[string]interface{}{ result := map[string]interface{}{
"id": ext.ID, "id": ext.ID,
"display_name": ext.Manifest.DisplayName, "display_name": ext.Manifest.DisplayName,
@@ -1348,8 +1275,6 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== EXTENSION AUTH API ====================
// GetExtensionPendingAuthJSON returns pending auth request for an extension // 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)
@@ -1429,9 +1354,6 @@ func GetAllPendingAuthRequestsJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== EXTENSION FFMPEG API ====================
// GetPendingFFmpegCommandJSON returns a pending FFmpeg command for Flutter to execute
func GetPendingFFmpegCommandJSON(commandID string) (string, error) { func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
cmd := GetPendingFFmpegCommand(commandID) cmd := GetPendingFFmpegCommand(commandID)
if cmd == nil { if cmd == nil {
@@ -1491,7 +1413,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
manager := GetExtensionManager() manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID) ext, err := manager.GetExtension(extensionID)
if err != nil { if err != nil {
// Extension not found, return original track
return trackJSON, nil return trackJSON, nil
} }
@@ -1595,10 +1516,6 @@ func GetSearchProvidersJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== EXTENSION URL HANDLER ====================
// HandleURLWithExtensionJSON tries to handle a URL with any matching extension
// Returns JSON with type, tracks, album info, etc.
func HandleURLWithExtensionJSON(url string) (string, error) { func HandleURLWithExtensionJSON(url string) (string, error) {
manager := GetExtensionManager() manager := GetExtensionManager()
resultWithID, err := manager.HandleURLWithExtension(url) resultWithID, err := manager.HandleURLWithExtension(url)
@@ -1860,7 +1777,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
return "", fmt.Errorf("failed to marshal result: %w", err) return "", fmt.Errorf("failed to marshal result: %w", err)
} }
// Parse into album metadata (same structure)
var album ExtAlbumMetadata var album ExtAlbumMetadata
if err := json.Unmarshal(jsonBytes, &album); err != nil { if err := json.Unmarshal(jsonBytes, &album); err != nil {
return "", fmt.Errorf("failed to parse playlist: %w", err) return "", fmt.Errorf("failed to parse playlist: %w", err)
@@ -1961,7 +1877,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
response["header_image"] = artist.HeaderImage response["header_image"] = artist.HeaderImage
} }
// Add listeners if present
if artist.Listeners > 0 { if artist.Listeners > 0 {
response["listeners"] = artist.Listeners response["listeners"] = artist.Listeners
} }
@@ -2019,9 +1934,6 @@ func GetURLHandlersJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== EXTENSION POST-PROCESSING ====================
// RunPostProcessingJSON runs post-processing hooks on a file
func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) { func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
var metadata map[string]interface{} var metadata map[string]interface{}
if metadataJSON != "" { if metadataJSON != "" {
@@ -2077,8 +1989,6 @@ func GetPostProcessingProvidersJSON() (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== EXTENSION STORE ====================
// InitExtensionStoreJSON initializes the extension store with cache directory // InitExtensionStoreJSON initializes the extension store with cache directory
func InitExtensionStoreJSON(cacheDir string) error { func InitExtensionStoreJSON(cacheDir string) error {
InitExtensionStore(cacheDir) InitExtensionStore(cacheDir)
@@ -2092,7 +2002,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
return "", fmt.Errorf("extension store not initialized") return "", fmt.Errorf("extension store not initialized")
} }
// Force refresh if requested
if forceRefresh { if forceRefresh {
store.FetchRegistry(true) store.FetchRegistry(true)
} }
+1 -25
View File
@@ -1,4 +1,3 @@
// Package gobackend provides extension management functionality
package gobackend package gobackend
import ( import (
@@ -15,8 +14,6 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
// compareVersions compares two semantic version strings
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
func compareVersions(v1, v2 string) int { func compareVersions(v1, v2 string) int {
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".") parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".") parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
@@ -46,7 +43,6 @@ func compareVersions(v1, v2 string) int {
return 0 return 0
} }
// LoadedExtension represents an extension that has been loaded into memory
type LoadedExtension struct { type LoadedExtension struct {
ID string `json:"id"` ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"` Manifest *ExtensionManifest `json:"manifest"`
@@ -72,7 +68,6 @@ var (
globalExtManagerOnce sync.Once globalExtManagerOnce sync.Once
) )
// GetExtensionManager returns the global extension manager instance
func GetExtensionManager() *ExtensionManager { func GetExtensionManager() *ExtensionManager {
globalExtManagerOnce.Do(func() { globalExtManagerOnce.Do(func() {
globalExtManager = &ExtensionManager{ globalExtManager = &ExtensionManager{
@@ -82,7 +77,6 @@ func GetExtensionManager() *ExtensionManager {
return globalExtManager return globalExtManager
} }
// SetDirectories sets the extensions and data directories
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error { func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -100,9 +94,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
return nil return nil
} }
// LoadExtensionFromFile loads an extension from a .spotiflac-ext file
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) { func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
// Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
} }
@@ -181,14 +173,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return nil, fmt.Errorf("failed to create extension directory: %w", err) return nil, fmt.Errorf("failed to create extension directory: %w", err)
} }
// Extract all files (preserving directory structure)
for _, file := range zipReader.File { for _, file := range zipReader.File {
if file.FileInfo().IsDir() { if file.FileInfo().IsDir() {
continue continue
} }
// Preserve relative path within the zip (support subdirectories)
// Clean the path to prevent path traversal attacks
relPath := filepath.Clean(file.Name) relPath := filepath.Clean(file.Name)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name) GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
@@ -246,7 +235,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return ext, nil return ext, nil
} }
// initializeVM creates and initializes the Goja VM for an extension
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
vm := goja.New() vm := goja.New()
ext.VM = vm ext.VM = vm
@@ -323,7 +311,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return nil return nil
} }
// GetExtension returns a loaded extension by ID
// Returns error if extension not found (gomobile compatible) // 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()
@@ -348,7 +335,6 @@ func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
return result return result
} }
// SetExtensionEnabled enables or disables an extension
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error { func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -409,7 +395,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
return loaded, errors return loaded, errors
} }
// loadExtensionFromDirectory loads an extension from an already extracted directory
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) { func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -498,7 +483,6 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
return nil return nil
} }
// UpgradeExtension upgrades an existing extension from a new package file
// Only allows upgrades (new version > current version), not downgrades // Only allows upgrades (new version > current version), not downgrades
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) { func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
// Validate file extension // Validate file extension
@@ -645,7 +629,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
return ext, nil return ext, nil
} }
// ExtensionUpgradeInfo holds information about extension upgrade check
type ExtensionUpgradeInfo struct { type ExtensionUpgradeInfo struct {
ExtensionID string `json:"extension_id"` ExtensionID string `json:"extension_id"`
CurrentVersion string `json:"current_version"` CurrentVersion string `json:"current_version"`
@@ -717,7 +700,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
return info, nil return info, nil
} }
// CheckExtensionUpgradeJSON checks if a package file is an upgrade and returns JSON
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) { func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
info, err := m.checkExtensionUpgradeInternal(filePath) info, err := m.checkExtensionUpgradeInternal(filePath)
if err != nil { if err != nil {
@@ -827,7 +809,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
// ==================== Extension Lifecycle ==================== // ==================== Extension Lifecycle ====================
// InitializeExtension calls the extension's initialize method with settings
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()
@@ -889,7 +870,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return nil return nil
} }
// CleanupExtension calls the extension's cleanup method
func (m *ExtensionManager) CleanupExtension(extensionID string) error { func (m *ExtensionManager) CleanupExtension(extensionID string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -900,10 +880,9 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
} }
if ext.VM == nil { if ext.VM == nil {
return nil // No VM, nothing to cleanup return nil
} }
// Call cleanup function
script := ` script := `
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') { if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
@@ -952,16 +931,13 @@ func (m *ExtensionManager) UnloadAllExtensions() {
m.mu.Unlock() m.mu.Unlock()
for _, id := range extensionIDs { for _, id := range extensionIDs {
// Call cleanup first
m.CleanupExtension(id) m.CleanupExtension(id)
// Then unload
m.UnloadExtension(id) m.UnloadExtension(id)
} }
GoLog("[Extension] All extensions unloaded\n") GoLog("[Extension] All extensions unloaded\n")
} }
// InvokeAction calls a custom action function on an extension (e.g., for button settings)
// The function is called as extension.<actionName>() and can return a result // 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()
-20
View File
@@ -151,9 +151,7 @@ func ParseManifest(data []byte) (*ExtensionManifest, error) {
return &manifest, nil return &manifest, nil
} }
// Validate checks if the manifest has all required fields and valid values
func (m *ExtensionManifest) Validate() error { func (m *ExtensionManifest) Validate() error {
// Check required fields
if strings.TrimSpace(m.Name) == "" { if strings.TrimSpace(m.Name) == "" {
return &ManifestValidationError{Field: "name", Message: "name is required"} return &ManifestValidationError{Field: "name", Message: "name is required"}
} }
@@ -174,7 +172,6 @@ func (m *ExtensionManifest) Validate() error {
return &ManifestValidationError{Field: "type", Message: "at least one type is required"} return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
} }
// Validate extension types
for _, t := range m.Types { for _, t := range m.Types {
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider { if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
return &ManifestValidationError{ return &ManifestValidationError{
@@ -200,21 +197,6 @@ func (m *ExtensionManifest) Validate() error {
} }
} }
// Validate setting type
validTypes := map[SettingType]bool{
SettingTypeString: true,
SettingTypeNumber: true,
SettingTypeBool: true,
SettingTypeSelect: true,
SettingTypeButton: true,
}
if !validTypes[setting.Type] {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].type", i),
Message: fmt.Sprintf("invalid setting type: %s", setting.Type),
}
}
// Select type requires options // Select type requires options
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 { if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
return &ManifestValidationError{ return &ManifestValidationError{
@@ -223,7 +205,6 @@ func (m *ExtensionManifest) Validate() error {
} }
} }
// Button type requires action
if setting.Type == SettingTypeButton && setting.Action == "" { if setting.Type == SettingTypeButton && setting.Action == "" {
return &ManifestValidationError{ return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].action", i), Field: fmt.Sprintf("settings[%d].action", i),
@@ -300,7 +281,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
return false return false
} }
// Parse URL to get host
urlStr = strings.ToLower(strings.TrimSpace(urlStr)) urlStr = strings.ToLower(strings.TrimSpace(urlStr))
for _, pattern := range m.URLHandler.Patterns { for _, pattern := range m.URLHandler.Patterns {
pattern = strings.ToLower(strings.TrimSpace(pattern)) pattern = strings.ToLower(strings.TrimSpace(pattern))
+4 -25
View File
@@ -1,4 +1,3 @@
// Package gobackend provides extension runtime with sandboxed execution
package gobackend package gobackend
import ( import (
@@ -17,7 +16,6 @@ var (
extensionAuthStateMu sync.RWMutex extensionAuthStateMu sync.RWMutex
) )
// ExtensionAuthState holds auth state for an extension
type ExtensionAuthState struct { type ExtensionAuthState struct {
PendingAuthURL string PendingAuthURL string
AuthCode string AuthCode string
@@ -30,7 +28,6 @@ type ExtensionAuthState struct {
PKCEChallenge string PKCEChallenge string
} }
// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL
type PendingAuthRequest struct { type PendingAuthRequest struct {
ExtensionID string ExtensionID string
AuthURL string AuthURL string
@@ -55,7 +52,6 @@ func ClearPendingAuthRequest(extensionID string) {
delete(pendingAuthRequests, extensionID) delete(pendingAuthRequests, extensionID)
} }
// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback)
func SetExtensionAuthCode(extensionID string, authCode string) { func SetExtensionAuthCode(extensionID string, authCode string) {
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock() defer extensionAuthStateMu.Unlock()
@@ -68,7 +64,6 @@ func SetExtensionAuthCode(extensionID string, authCode string) {
state.AuthCode = authCode state.AuthCode = authCode
} }
// SetExtensionTokens sets access/refresh tokens for an extension
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) { func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
defer extensionAuthStateMu.Unlock() defer extensionAuthStateMu.Unlock()
@@ -84,7 +79,6 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
state.IsAuthenticated = accessToken != "" state.IsAuthenticated = accessToken != ""
} }
// ExtensionRuntime provides sandboxed APIs for extensions
type ExtensionRuntime struct { type ExtensionRuntime struct {
extensionID string extensionID string
manifest *ExtensionManifest manifest *ExtensionManifest
@@ -95,7 +89,6 @@ type ExtensionRuntime struct {
vm *goja.Runtime vm *goja.Runtime
} }
// NewExtensionRuntime creates a new runtime for an extension
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
jar, _ := newSimpleCookieJar() jar, _ := newSimpleCookieJar()
@@ -108,7 +101,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
vm: ext.VM, vm: ext.VM,
} }
// Create HTTP client with redirect validation to prevent SSRF via open redirect
client := &http.Client{ client := &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
Jar: jar, Jar: jar,
@@ -119,7 +111,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain) GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain} return &RedirectBlockedError{Domain: domain}
} }
// Also block redirects to private/local networks (SSRF protection)
if isPrivateIP(domain) { if isPrivateIP(domain) {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain) GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true} return &RedirectBlockedError{Domain: domain, IsPrivate: true}
@@ -136,7 +127,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
return runtime return runtime
} }
// RedirectBlockedError is returned when a redirect is blocked due to domain validation
type RedirectBlockedError struct { type RedirectBlockedError struct {
Domain string Domain string
IsPrivate bool IsPrivate bool
@@ -162,10 +152,10 @@ func isPrivateIP(host string) bool {
"172.24.", "172.25.", "172.26.", "172.27.", "172.24.", "172.25.", "172.26.", "172.27.",
"172.28.", "172.29.", "172.30.", "172.31.", "172.28.", "172.29.", "172.30.", "172.31.",
"192.168.", "192.168.",
"169.254.", // Link-local "169.254.",
"::1", // IPv6 localhost "::1",
"fc00:", // IPv6 private "fc00:",
"fe80:", // IPv6 link-local "fe80:",
} }
hostLower := host hostLower := host
@@ -183,7 +173,6 @@ func isPrivateIP(host string) bool {
return false return false
} }
// simpleCookieJar is a simple in-memory cookie jar
type simpleCookieJar struct { type simpleCookieJar struct {
cookies map[string][]*http.Cookie cookies map[string][]*http.Cookie
mu sync.RWMutex mu sync.RWMutex
@@ -208,7 +197,6 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
return j.cookies[u.Host] return j.cookies[u.Host]
} }
// SetSettings updates the runtime settings
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) { func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings r.settings = settings
} }
@@ -228,7 +216,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
httpObj.Set("clearCookies", r.httpClearCookies) httpObj.Set("clearCookies", r.httpClearCookies)
vm.Set("http", httpObj) vm.Set("http", httpObj)
// Storage API
storageObj := vm.NewObject() storageObj := vm.NewObject()
storageObj.Set("get", r.storageGet) storageObj.Set("get", r.storageGet)
storageObj.Set("set", r.storageSet) storageObj.Set("set", r.storageSet)
@@ -243,7 +230,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
credentialsObj.Set("has", r.credentialsHas) credentialsObj.Set("has", r.credentialsHas)
vm.Set("credentials", credentialsObj) vm.Set("credentials", credentialsObj)
// Auth API (for OAuth and other auth flows)
authObj := vm.NewObject() authObj := vm.NewObject()
authObj.Set("openAuthUrl", r.authOpenUrl) authObj.Set("openAuthUrl", r.authOpenUrl)
authObj.Set("getAuthCode", r.authGetCode) authObj.Set("getAuthCode", r.authGetCode)
@@ -270,7 +256,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
fileObj.Set("getSize", r.fileGetSize) fileObj.Set("getSize", r.fileGetSize)
vm.Set("file", fileObj) vm.Set("file", fileObj)
// FFmpeg API (for post-processing)
ffmpegObj := vm.NewObject() ffmpegObj := vm.NewObject()
ffmpegObj.Set("execute", r.ffmpegExecute) ffmpegObj.Set("execute", r.ffmpegExecute)
ffmpegObj.Set("getInfo", r.ffmpegGetInfo) ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
@@ -284,7 +269,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
matchingObj.Set("normalizeString", r.matchingNormalizeString) matchingObj.Set("normalizeString", r.matchingNormalizeString)
vm.Set("matching", matchingObj) vm.Set("matching", matchingObj)
// Utilities
utilsObj := vm.NewObject() utilsObj := vm.NewObject()
utilsObj.Set("base64Encode", r.base64Encode) utilsObj.Set("base64Encode", r.base64Encode)
utilsObj.Set("base64Decode", r.base64Decode) utilsObj.Set("base64Decode", r.base64Decode)
@@ -310,7 +294,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
logObj.Set("error", r.logError) logObj.Set("error", r.logError)
vm.Set("log", logObj) vm.Set("log", logObj)
// Go backend functions
gobackendObj := vm.NewObject() gobackendObj := vm.NewObject()
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper) gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
vm.Set("gobackend", gobackendObj) vm.Set("gobackend", gobackendObj)
@@ -321,16 +304,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
// Global fetch() - Promise-style HTTP API (browser-compatible) // Global fetch() - Promise-style HTTP API (browser-compatible)
vm.Set("fetch", r.fetchPolyfill) vm.Set("fetch", r.fetchPolyfill)
// Global atob/btoa - Base64 encoding (browser-compatible)
vm.Set("atob", r.atobPolyfill) vm.Set("atob", r.atobPolyfill)
vm.Set("btoa", r.btoaPolyfill) vm.Set("btoa", r.btoaPolyfill)
// TextEncoder/TextDecoder constructors
r.registerTextEncoderDecoder(vm) r.registerTextEncoderDecoder(vm)
// URL class for URL parsing
r.registerURLClass(vm) r.registerURLClass(vm)
// JSON global (browser-compatible)
r.registerJSONGlobal(vm) r.registerJSONGlobal(vm)
} }
+1 -28
View File
@@ -18,7 +18,6 @@ import (
// ==================== Auth API (OAuth Support) ==================== // ==================== Auth API (OAuth Support) ====================
// authOpenUrl requests Flutter to open an OAuth URL
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authOpenUrl(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{}{
@@ -33,7 +32,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
callbackURL = call.Arguments[1].String() callbackURL = call.Arguments[1].String()
} }
// Store pending auth request for Flutter to pick up
pendingAuthRequestsMu.Lock() pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID, ExtensionID: r.extensionID,
@@ -42,7 +40,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
} }
pendingAuthRequestsMu.Unlock() pendingAuthRequestsMu.Unlock()
// Update auth state
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID] state, exists := extensionAuthState[r.extensionID]
if !exists { if !exists {
@@ -50,7 +47,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
extensionAuthState[r.extensionID] = state extensionAuthState[r.extensionID] = state
} }
state.PendingAuthURL = authURL state.PendingAuthURL = authURL
state.AuthCode = "" // Clear any previous auth code state.AuthCode = ""
extensionAuthStateMu.Unlock() extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL) GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
@@ -61,7 +58,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
}) })
} }
// authGetCode gets the auth code (set by Flutter after OAuth callback)
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -114,7 +110,6 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(true) return r.vm.ToValue(true)
} }
// authClear clears all auth state for the extension
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
delete(extensionAuthState, r.extensionID) delete(extensionAuthState, r.extensionID)
@@ -138,7 +133,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
// Check if token is expired
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) { if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
return r.vm.ToValue(false) return r.vm.ToValue(false)
} }
@@ -146,7 +140,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
return r.vm.ToValue(state.IsAuthenticated) return r.vm.ToValue(state.IsAuthenticated)
} }
// authGetTokens returns current tokens (for extension to use in API calls)
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -182,16 +175,13 @@ func generatePKCEVerifier(length int) (string, error) {
length = 128 length = 128
} }
// Generate random bytes
bytes := make([]byte, length) bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil { if _, err := rand.Read(bytes); err != nil {
return "", err return "", err
} }
// Use base64url encoding without padding (RFC 7636 compliant)
verifier := base64.RawURLEncoding.EncodeToString(bytes) verifier := base64.RawURLEncoding.EncodeToString(bytes)
// Trim to exact length
if len(verifier) > length { if len(verifier) > length {
verifier = verifier[:length] verifier = verifier[:length]
} }
@@ -199,15 +189,12 @@ func generatePKCEVerifier(length int) (string, error) {
return verifier, nil return verifier, nil
} }
// generatePKCEChallenge generates a code challenge from verifier using S256 method
func generatePKCEChallenge(verifier string) string { func generatePKCEChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier)) hash := sha256.Sum256([]byte(verifier))
// Base64url encode without padding (RFC 7636) // Base64url encode without padding (RFC 7636)
return base64.RawURLEncoding.EncodeToString(hash[:]) return base64.RawURLEncoding.EncodeToString(hash[:])
} }
// authGeneratePKCE generates a PKCE code verifier and challenge pair
// Returns: { verifier: string, challenge: string, method: "S256" }
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
// Default length is 64 characters // Default length is 64 characters
length := 64 length := 64
@@ -227,7 +214,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
challenge := generatePKCEChallenge(verifier) challenge := generatePKCEChallenge(verifier)
// Store in auth state for later use
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
state, exists := extensionAuthState[r.extensionID] state, exists := extensionAuthState[r.extensionID]
if !exists { if !exists {
@@ -247,7 +233,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
}) })
} }
// authGetPKCE returns the current PKCE verifier and challenge (if generated)
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock() defer extensionAuthStateMu.RUnlock()
@@ -405,7 +390,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Get stored PKCE verifier
extensionAuthStateMu.RLock() extensionAuthStateMu.RLock()
state, exists := extensionAuthState[r.extensionID] state, exists := extensionAuthState[r.extensionID]
var verifier string var verifier string
@@ -421,7 +405,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Validate domain
if err := r.validateDomain(tokenURL); err != nil { if err := r.validateDomain(tokenURL); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -429,7 +412,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Build token request body
formData := url.Values{} formData := url.Values{}
formData.Set("grant_type", "authorization_code") formData.Set("grant_type", "authorization_code")
formData.Set("client_id", clientID) formData.Set("client_id", clientID)
@@ -439,14 +421,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
formData.Set("redirect_uri", redirectURI) formData.Set("redirect_uri", redirectURI)
} }
// Add extra params
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok { if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
for k, v := range extraParams { for k, v := range extraParams {
formData.Set(k, fmt.Sprintf("%v", v)) formData.Set(k, fmt.Sprintf("%v", v))
} }
} }
// Make token request
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode())) req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -475,7 +455,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Parse response
var tokenResp map[string]interface{} var tokenResp map[string]interface{}
if err := json.Unmarshal(body, &tokenResp); err != nil { if err := json.Unmarshal(body, &tokenResp); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -485,7 +464,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Check for error in response
if errMsg, ok := tokenResp["error"].(string); ok { if errMsg, ok := tokenResp["error"].(string); ok {
errDesc, _ := tokenResp["error_description"].(string) errDesc, _ := tokenResp["error_description"].(string)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -495,7 +473,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Extract tokens
accessToken, _ := tokenResp["access_token"].(string) accessToken, _ := tokenResp["access_token"].(string)
refreshToken, _ := tokenResp["refresh_token"].(string) refreshToken, _ := tokenResp["refresh_token"].(string)
expiresIn, _ := tokenResp["expires_in"].(float64) expiresIn, _ := tokenResp["expires_in"].(float64)
@@ -508,7 +485,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
}) })
} }
// Store tokens in auth state
extensionAuthStateMu.Lock() extensionAuthStateMu.Lock()
state, exists = extensionAuthState[r.extensionID] state, exists = extensionAuthState[r.extensionID]
if !exists { if !exists {
@@ -521,14 +497,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
if expiresIn > 0 { if expiresIn > 0 {
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
} }
// Clear PKCE after successful exchange
state.PKCEVerifier = "" state.PKCEVerifier = ""
state.PKCEChallenge = "" state.PKCEChallenge = ""
extensionAuthStateMu.Unlock() extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID) GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
// Return full token response
result := map[string]interface{}{ result := map[string]interface{}{
"success": true, "success": true,
"access_token": accessToken, "access_token": accessToken,
@@ -538,7 +512,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
if expiresIn > 0 { if expiresIn > 0 {
result["expires_in"] = expiresIn result["expires_in"] = expiresIn
} }
// Include any additional fields from response
if scope, ok := tokenResp["scope"].(string); ok { if scope, ok := tokenResp["scope"].(string); ok {
result["scope"] = scope result["scope"] = scope
} }
-6
View File
@@ -31,14 +31,12 @@ var (
ffmpegCommandID int64 ffmpegCommandID int64
) )
// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter)
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand { func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
ffmpegCommandsMu.RLock() ffmpegCommandsMu.RLock()
defer ffmpegCommandsMu.RUnlock() defer ffmpegCommandsMu.RUnlock()
return ffmpegCommands[commandID] return ffmpegCommands[commandID]
} }
// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter)
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) { func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
ffmpegCommandsMu.Lock() ffmpegCommandsMu.Lock()
defer ffmpegCommandsMu.Unlock() defer ffmpegCommandsMu.Unlock()
@@ -50,14 +48,12 @@ func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg str
} }
} }
// ClearFFmpegCommand removes a completed FFmpeg command
func ClearFFmpegCommand(commandID string) { func ClearFFmpegCommand(commandID string) {
ffmpegCommandsMu.Lock() ffmpegCommandsMu.Lock()
defer ffmpegCommandsMu.Unlock() defer ffmpegCommandsMu.Unlock()
delete(ffmpegCommands, commandID) delete(ffmpegCommands, commandID)
} }
// ffmpegExecute queues an FFmpeg command for execution by Flutter
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) ffmpegExecute(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{}{
@@ -118,7 +114,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
} }
} }
// ffmpegGetInfo gets audio file information using FFprobe
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) ffmpegGetInfo(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{}{
@@ -147,7 +142,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
}) })
} }
// ffmpegConvert is a helper for common conversion operations
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) ffmpegConvert(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{}{
+1 -39
View File
@@ -21,8 +21,6 @@ var (
allowedDownloadDirsMu sync.RWMutex allowedDownloadDirsMu sync.RWMutex
) )
// SetAllowedDownloadDirs sets the list of directories where extensions can write files
// This should be called by the Go backend when setting up download paths
func SetAllowedDownloadDirs(dirs []string) { func SetAllowedDownloadDirs(dirs []string) {
allowedDownloadDirsMu.Lock() allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock() defer allowedDownloadDirsMu.Unlock()
@@ -30,7 +28,6 @@ func SetAllowedDownloadDirs(dirs []string) {
GoLog("[Extension] Allowed download directories set: %v\n", dirs) GoLog("[Extension] Allowed download directories set: %v\n", dirs)
} }
// AddAllowedDownloadDir adds a directory to the allowed list
func AddAllowedDownloadDir(dir string) { func AddAllowedDownloadDir(dir string) {
allowedDownloadDirsMu.Lock() allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock() defer allowedDownloadDirsMu.Unlock()
@@ -40,7 +37,6 @@ func AddAllowedDownloadDir(dir string) {
} }
} }
// isPathInAllowedDirs checks if an absolute path is within any allowed directory
func isPathInAllowedDirs(absPath string) bool { func isPathInAllowedDirs(absPath string) bool {
allowedDownloadDirsMu.RLock() allowedDownloadDirsMu.RLock()
defer allowedDownloadDirsMu.RUnlock() defer allowedDownloadDirsMu.RUnlock()
@@ -62,36 +58,28 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
return "", fmt.Errorf("file access denied: extension does not have 'file' permission") return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
} }
// Clean and resolve the path
cleanPath := filepath.Clean(path) cleanPath := filepath.Clean(path)
// SECURITY: Block absolute paths by default
// Only allow if path is in explicitly allowed download directories
if filepath.IsAbs(cleanPath) { if filepath.IsAbs(cleanPath) {
absPath, err := filepath.Abs(cleanPath) absPath, err := filepath.Abs(cleanPath)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid path: %w", err) return "", fmt.Errorf("invalid path: %w", err)
} }
// Check if path is in allowed download directories
if isPathInAllowedDirs(absPath) { if isPathInAllowedDirs(absPath) {
return absPath, nil return absPath, nil
} }
// Block all other absolute paths
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox") return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
} }
// For relative paths, join with data directory (extension's sandbox)
fullPath := filepath.Join(r.dataDir, cleanPath) fullPath := filepath.Join(r.dataDir, cleanPath)
// Resolve to absolute path
absPath, err := filepath.Abs(fullPath) absPath, err := filepath.Abs(fullPath)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid path: %w", err) return "", fmt.Errorf("invalid path: %w", err)
} }
// Ensure path is within data directory (prevent path traversal)
absDataDir, _ := filepath.Abs(r.dataDir) absDataDir, _ := filepath.Abs(r.dataDir)
if !strings.HasPrefix(absPath, absDataDir) { if !strings.HasPrefix(absPath, absDataDir) {
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path) return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
@@ -100,8 +88,6 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
return absPath, nil return absPath, nil
} }
// fileDownload downloads a file from URL to the specified path
// Supports progress callback via options.onProgress
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileDownload(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{}{
@@ -113,7 +99,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
outputPath := call.Arguments[1].String() outputPath := call.Arguments[1].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -121,7 +106,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Validate output path (allows absolute paths for download queue)
fullPath, err := r.validatePath(outputPath) fullPath, err := r.validatePath(outputPath)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -130,20 +114,17 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Get options if provided
var onProgress goja.Callable var onProgress goja.Callable
var headers map[string]string var headers 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]) {
optionsObj := call.Arguments[2].Export() optionsObj := call.Arguments[2].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok { if opts, ok := optionsObj.(map[string]interface{}); ok {
// Extract headers
if h, ok := opts["headers"].(map[string]interface{}); ok { if h, ok := opts["headers"].(map[string]interface{}); ok {
headers = make(map[string]string) headers = make(map[string]string)
for k, v := range h { for k, v := range h {
headers[k] = fmt.Sprintf("%v", v) headers[k] = fmt.Sprintf("%v", v)
} }
} }
// Extract onProgress callback
if progressVal, ok := opts["onProgress"]; ok { if progressVal, ok := opts["onProgress"]; ok {
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok { if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
onProgress = callable onProgress = callable
@@ -152,7 +133,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
} }
} }
// Create directory if needed
dir := filepath.Dir(fullPath) dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -161,7 +141,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Create HTTP request
req, err := http.NewRequest("GET", urlStr, nil) req, err := http.NewRequest("GET", urlStr, nil)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -170,7 +149,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Set headers
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
@@ -178,7 +156,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
} }
// Download file
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{}{
@@ -195,7 +172,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Create output file
out, err := os.Create(fullPath) out, err := os.Create(fullPath)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -205,12 +181,10 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
} }
defer out.Close() defer out.Close()
// Get content length for progress
contentLength := resp.ContentLength contentLength := resp.ContentLength
// Copy content with progress reporting
var written int64 var written int64
buf := make([]byte, 32*1024) // 32KB buffer buf := make([]byte, 32*1024)
for { for {
nr, er := resp.Body.Read(buf) nr, er := resp.Body.Read(buf)
if nr > 0 { if nr > 0 {
@@ -235,7 +209,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// Report progress
if onProgress != nil && contentLength > 0 { if onProgress != nil && contentLength > 0 {
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength)) _, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
} }
@@ -260,7 +233,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}) })
} }
// fileExists checks if a file exists in the sandbox
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileExists(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)
@@ -276,7 +248,6 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(err == nil) return r.vm.ToValue(err == nil)
} }
// fileDelete deletes a file in the sandbox
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileDelete(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{}{
@@ -306,7 +277,6 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
}) })
} }
// fileRead reads a file from the sandbox
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileRead(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{}{
@@ -338,7 +308,6 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
}) })
} }
// fileWrite writes data to a file in the sandbox
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileWrite(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{}{
@@ -380,7 +349,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
}) })
} }
// fileCopy copies a file within the sandbox
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileCopy(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{}{
@@ -408,7 +376,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
}) })
} }
// Read source file
data, err := os.ReadFile(fullSrc) data, err := os.ReadFile(fullSrc)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -417,7 +384,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
}) })
} }
// Create destination directory if needed
dir := filepath.Dir(fullDst) dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -426,7 +392,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
}) })
} }
// Write to destination
if err := os.WriteFile(fullDst, data, 0644); err != nil { if err := os.WriteFile(fullDst, data, 0644); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
@@ -440,7 +405,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
}) })
} }
// fileMove moves/renames a file within the sandbox
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileMove(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{}{
@@ -468,7 +432,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
}) })
} }
// Create destination directory if needed
dir := filepath.Dir(fullDst) dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -490,7 +453,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
}) })
} }
// fileGetSize returns the size of a file in bytes
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) fileGetSize(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{}{
-16
View File
@@ -52,7 +52,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -60,7 +59,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
}) })
} }
// Get headers if provided
headers := make(map[string]string) headers := make(map[string]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]) {
headersObj := call.Arguments[1].Export() headersObj := call.Arguments[1].Export()
@@ -71,7 +69,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
} }
} }
// Create request
req, err := http.NewRequest("GET", urlStr, nil) req, err := http.NewRequest("GET", urlStr, nil)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -97,7 +94,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -134,7 +130,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -175,7 +170,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
} }
} }
// Create request
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr)) req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -204,7 +198,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -231,8 +224,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
}) })
} }
// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.)
// Usage: http.request(url, options) where options = { method, body, headers }
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpRequest(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{}{
@@ -242,7 +233,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -326,7 +316,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -354,7 +343,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
}) })
} }
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call) return r.httpMethodShortcut("PUT", call)
} }
@@ -364,7 +352,6 @@ func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("DELETE", call) return r.httpMethodShortcut("DELETE", call)
} }
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call) return r.httpMethodShortcut("PATCH", call)
} }
@@ -380,7 +367,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
urlStr := call.Arguments[0].String() urlStr := call.Arguments[0].String()
// Validate domain
if err := r.validateDomain(urlStr); err != nil { if err := r.validateDomain(urlStr); err != nil {
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -465,7 +451,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
@@ -492,7 +477,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
}) })
} }
// httpClearCookies clears all cookies for this extension
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
if jar, ok := r.cookieJar.(*simpleCookieJar); ok { if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
jar.mu.Lock() jar.mu.Lock()
-4
View File
@@ -143,19 +143,16 @@ func (r *ExtensionRuntime) getSaltPath() string {
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) { func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
saltPath := r.getSaltPath() saltPath := r.getSaltPath()
// Try to read existing salt
salt, err := os.ReadFile(saltPath) salt, err := os.ReadFile(saltPath)
if err == nil && len(salt) == 32 { if err == nil && len(salt) == 32 {
return salt, nil return salt, nil
} }
// Generate new random salt (32 bytes)
salt = make([]byte, 32) salt = make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, salt); err != nil { if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err) return nil, fmt.Errorf("failed to generate salt: %w", err)
} }
// Save salt to file
if err := os.WriteFile(saltPath, salt, 0600); err != nil { if err := os.WriteFile(saltPath, salt, 0600); err != nil {
return nil, fmt.Errorf("failed to save salt: %w", err) return nil, fmt.Errorf("failed to save salt: %w", err)
} }
@@ -214,7 +211,6 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
return err return err
} }
// Encrypt the data
key, err := r.getEncryptionKey() key, err := r.getEncryptionKey()
if err != nil { if err != nil {
return fmt.Errorf("failed to get encryption key: %w", err) return fmt.Errorf("failed to get encryption key: %w", err)
-3
View File
@@ -94,7 +94,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue([]byte{}) return r.vm.ToValue([]byte{})
} }
// Get key - can be string or array of bytes
var keyBytes []byte var keyBytes []byte
keyArg := call.Arguments[0].Export() keyArg := call.Arguments[0].Export()
switch k := keyArg.(type) { switch k := keyArg.(type) {
@@ -113,7 +112,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
return r.vm.ToValue([]byte{}) return r.vm.ToValue([]byte{})
} }
// Get message - can be string or array of bytes
var msgBytes []byte var msgBytes []byte
msgArg := call.Arguments[1].Export() msgArg := call.Arguments[1].Export()
switch m := msgArg.(type) { switch m := msgArg.(type) {
@@ -136,7 +134,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
mac.Write(msgBytes) mac.Write(msgBytes)
result := mac.Sum(nil) result := mac.Sum(nil)
// Convert to array of numbers for JavaScript
jsArray := make([]interface{}, len(result)) jsArray := make([]interface{}, len(result))
for i, b := range result { for i, b := range result {
jsArray[i] = int(b) jsArray[i] = int(b)
-4
View File
@@ -42,7 +42,6 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
return fmt.Errorf("failed to create settings directory: %w", err) return fmt.Errorf("failed to create settings directory: %w", err)
} }
// Load all existing settings
return s.loadAllSettings() return s.loadAllSettings()
} }
@@ -99,7 +98,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
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)
// Create directory if needed
dir := filepath.Dir(settingsPath) dir := filepath.Dir(settingsPath)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return err return err
@@ -160,7 +158,6 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
s.settings[extensionID][key] = value s.settings[extensionID][key] = value
// Persist to disk
return s.saveSettings(extensionID, s.settings[extensionID]) return s.saveSettings(extensionID, s.settings[extensionID])
} }
@@ -198,7 +195,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
delete(s.settings, extensionID) delete(s.settings, extensionID)
// Remove settings file
settingsPath := s.getSettingsPath(extensionID) settingsPath := s.getSettingsPath(extensionID)
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) { if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
return err return err
-2
View File
@@ -35,7 +35,6 @@ type StoreExtension struct {
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"`
// Alternative camelCase fields (for flexibility)
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"`
@@ -332,7 +331,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return fmt.Errorf("download returned HTTP %d", resp.StatusCode) return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
} }
// Create destination file
out, err := os.Create(destPath) out, err := os.Create(destPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to create file: %w", err) return fmt.Errorf("failed to create file: %w", err)
-4
View File
@@ -6,10 +6,8 @@ import (
"strings" "strings"
) )
// Invalid filename characters for Android/Windows/Linux
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`) var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(filename string) string { func sanitizeFilename(filename string) string {
sanitized := invalidChars.ReplaceAllString(filename, "_") sanitized := invalidChars.ReplaceAllString(filename, "_")
@@ -30,7 +28,6 @@ func sanitizeFilename(filename string) string {
return sanitized return sanitized
} }
// buildFilenameFromTemplate builds a filename from template and metadata
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string { func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
if template == "" { if template == "" {
template = "{artist} - {title}" template = "{artist} - {title}"
@@ -91,7 +88,6 @@ func formatDiscNumber(n int) string {
return fmt.Sprintf("%d", n) return fmt.Sprintf("%d", n)
} }
// extractYear extracts year from date string (YYYY-MM-DD or YYYY)
func extractYear(date string) string { func extractYear(date string) string {
if len(date) >= 4 { if len(date) >= 4 {
return date[:4] return date[:4]
-46
View File
@@ -15,8 +15,6 @@ import (
"time" "time"
) )
// HTTP utility functions for consistent request handling across all downloaders
// getRandomUserAgent generates a random Windows Chrome User-Agent string // getRandomUserAgent generates a random Windows Chrome User-Agent string
// Uses modern Chrome format with build and patch numbers // Uses modern Chrome format with build and patch numbers
// Windows 11 still reports as "Windows NT 10.0" for compatibility // Windows 11 still reports as "Windows NT 10.0" for compatibility
@@ -34,41 +32,6 @@ func getRandomUserAgent() string {
) )
} }
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
// Alternative format matching referensi/backend/spotify_metadata.go exactly
// func getRandomMacUserAgent() string {
// macMajor := rand.Intn(4) + 11 // macOS 11-14
// macMinor := rand.Intn(5) + 4 // Minor 4-8
// webkitMajor := rand.Intn(7) + 530
// webkitMinor := rand.Intn(7) + 30
// chromeMajor := rand.Intn(25) + 80
// chromeBuild := rand.Intn(1500) + 3000
// chromePatch := rand.Intn(65) + 60
// safariMajor := rand.Intn(7) + 530
// safariMinor := rand.Intn(6) + 30
//
// return fmt.Sprintf(
// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
// macMajor,
// macMinor,
// webkitMajor,
// webkitMinor,
// chromeMajor,
// chromeBuild,
// chromePatch,
// safariMajor,
// safariMinor,
// )
// }
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
// func getRandomDesktopUserAgent() string {
// if rand.Intn(2) == 0 {
// return getRandomUserAgent() // Windows
// }
// return getRandomMacUserAgent() // Mac
// }
const ( const (
DefaultTimeout = 60 * time.Second DefaultTimeout = 60 * time.Second
DownloadTimeout = 120 * time.Second DownloadTimeout = 120 * time.Second
@@ -106,7 +69,6 @@ var downloadClient = &http.Client{
Timeout: DownloadTimeout, Timeout: DownloadTimeout,
} }
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
return &http.Client{ return &http.Client{
Transport: sharedTransport, Transport: sharedTransport,
@@ -127,7 +89,6 @@ func CloseIdleConnections() {
sharedTransport.CloseIdleConnections() sharedTransport.CloseIdleConnections()
} }
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
// Also checks for ISP blocking on errors // Also checks for ISP blocking on errors
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", getRandomUserAgent())
@@ -146,7 +107,6 @@ type RetryConfig struct {
BackoffFactor float64 BackoffFactor float64
} }
// DefaultRetryConfig returns default retry configuration
func DefaultRetryConfig() RetryConfig { func DefaultRetryConfig() RetryConfig {
return RetryConfig{ return RetryConfig{
MaxRetries: DefaultMaxRetries, MaxRetries: DefaultMaxRetries,
@@ -252,13 +212,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr) return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
} }
// calculateNextDelay calculates the next delay with exponential backoff
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration { func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor) nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
return min(nextDelay, config.MaxDelay) return min(nextDelay, config.MaxDelay)
} }
// getRetryAfterDuration parses Retry-After header and returns duration
// Returns 60 seconds as default if header is missing or invalid // Returns 60 seconds as default if header is missing or invalid
func getRetryAfterDuration(resp *http.Response) time.Duration { func getRetryAfterDuration(resp *http.Response) time.Duration {
retryAfter := resp.Header.Get("Retry-After") retryAfter := resp.Header.Get("Retry-After")
@@ -301,7 +259,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) {
return body, nil return body, nil
} }
// ValidateResponse checks if response is valid (non-nil, status 2xx)
func ValidateResponse(resp *http.Response) error { func ValidateResponse(resp *http.Response) error {
if resp == nil { if resp == nil {
return fmt.Errorf("response is nil") return fmt.Errorf("response is nil")
@@ -330,7 +287,6 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
return msg return msg
} }
// ISPBlockingError represents an error caused by ISP blocking
type ISPBlockingError struct { type ISPBlockingError struct {
Domain string Domain string
Reason string Reason string
@@ -446,7 +402,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
return nil return nil
} }
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
// Returns true if ISP blocking was detected // Returns true if ISP blocking was detected
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool { func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
ispErr := IsISPBlocking(err, requestURL) ispErr := IsISPBlocking(err, requestURL)
@@ -484,7 +439,6 @@ func extractDomain(rawURL string) string {
return "unknown" return "unknown"
} }
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
// If ISP blocking is detected, returns a more descriptive error // If ISP blocking is detected, returns a more descriptive error
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error { func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil { if err == nil {
-4
View File
@@ -8,7 +8,6 @@ import (
"time" "time"
) )
// LogEntry represents a single log entry
type LogEntry struct { type LogEntry struct {
Timestamp string `json:"timestamp"` Timestamp string `json:"timestamp"`
Level string `json:"level"` Level string `json:"level"`
@@ -16,7 +15,6 @@ type LogEntry struct {
Message string `json:"message"` Message string `json:"message"`
} }
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
type LogBuffer struct { type LogBuffer struct {
entries []LogEntry entries []LogEntry
maxSize int maxSize int
@@ -41,7 +39,6 @@ func GetLogBuffer() *LogBuffer {
return globalLogBuffer return globalLogBuffer
} }
// SetLoggingEnabled enables or disables logging
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) { func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.mu.Lock() lb.mu.Lock()
defer lb.mu.Unlock() defer lb.mu.Unlock()
@@ -55,7 +52,6 @@ func (lb *LogBuffer) IsLoggingEnabled() bool {
return lb.loggingEnabled return lb.loggingEnabled
} }
// Add adds a log entry to the buffer
func (lb *LogBuffer) Add(level, tag, message string) { func (lb *LogBuffer) Add(level, tag, message string) {
lb.mu.Lock() lb.mu.Lock()
defer lb.mu.Unlock() defer lb.mu.Unlock()
+2 -33
View File
@@ -15,13 +15,9 @@ import (
"time" "time"
) )
// ========================================
// Lyrics Cache with TTL
// ========================================
const ( const (
lyricsCacheTTL = 24 * time.Hour // Cache lyrics for 24 hours lyricsCacheTTL = 24 * time.Hour
durationToleranceSec = 10.0 // Duration matching tolerance in seconds durationToleranceSec = 10.0
) )
type lyricsCacheEntry struct { type lyricsCacheEntry struct {
@@ -39,10 +35,8 @@ var globalLyricsCache = &lyricsCache{
} }
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string { func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
// Normalize key: lowercase, trim spaces
normalizedArtist := strings.ToLower(strings.TrimSpace(artist)) normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
normalizedTrack := strings.ToLower(strings.TrimSpace(track)) normalizedTrack := strings.ToLower(strings.TrimSpace(track))
// Round duration to nearest 10 seconds for cache key
roundedDuration := math.Round(durationSec/10) * 10 roundedDuration := math.Round(durationSec/10) * 10
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration) return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
} }
@@ -57,7 +51,6 @@ func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsRes
return nil, false return nil, false
} }
// Check if expired
if time.Now().After(entry.expiresAt) { if time.Now().After(entry.expiresAt) {
return nil, false return nil, false
} }
@@ -76,7 +69,6 @@ func (c *lyricsCache) Set(artist, track string, durationSec float64, response *L
} }
} }
// CleanExpired removes expired entries from cache
func (c *lyricsCache) CleanExpired() int { func (c *lyricsCache) CleanExpired() int {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -92,7 +84,6 @@ func (c *lyricsCache) CleanExpired() int {
return cleaned return cleaned
} }
// Size returns current cache size
func (c *lyricsCache) Size() int { func (c *lyricsCache) Size() int {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -174,8 +165,6 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
return c.parseLRCLibResponse(&lrcResp), nil return c.parseLRCLibResponse(&lrcResp), nil
} }
// FetchLyricsFromLRCLibSearch searches lyrics with optional duration matching
// durationSec: track duration in seconds, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) { func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
baseURL := "https://lrclib.net/api/search" baseURL := "https://lrclib.net/api/search"
params := url.Values{} params := url.Values{}
@@ -208,13 +197,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
return nil, fmt.Errorf("no lyrics found") return nil, fmt.Errorf("no lyrics found")
} }
// Filter and score results based on duration matching and synced lyrics
bestMatch := c.findBestMatch(results, durationSec) bestMatch := c.findBestMatch(results, durationSec)
if bestMatch != nil { if bestMatch != nil {
return c.parseLRCLibResponse(bestMatch), nil return c.parseLRCLibResponse(bestMatch), nil
} }
// Fallback: return first result with synced lyrics
for _, result := range results { for _, result := range results {
if result.SyncedLyrics != "" { if result.SyncedLyrics != "" {
return c.parseLRCLibResponse(&result), nil return c.parseLRCLibResponse(&result), nil
@@ -224,7 +211,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
return c.parseLRCLibResponse(&results[0]), nil return c.parseLRCLibResponse(&results[0]), nil
} }
// findBestMatch finds the best matching lyrics based on duration and sync status
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse { func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
var bestSynced *LRCLibResponse var bestSynced *LRCLibResponse
var bestPlain *LRCLibResponse var bestPlain *LRCLibResponse
@@ -232,11 +218,9 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
for i := range results { for i := range results {
result := &results[i] result := &results[i]
// Check duration match if target duration is provided
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec) durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
if durationMatches { if durationMatches {
// Prefer synced lyrics over plain
if result.SyncedLyrics != "" && bestSynced == nil { if result.SyncedLyrics != "" && bestSynced == nil {
bestSynced = result bestSynced = result
} else if result.PlainLyrics != "" && bestPlain == nil { } else if result.PlainLyrics != "" && bestPlain == nil {
@@ -245,20 +229,17 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
} }
} }
// Return synced first, then plain
if bestSynced != nil { if bestSynced != nil {
return bestSynced return bestSynced
} }
return bestPlain return bestPlain
} }
// durationMatches checks if two durations are within tolerance
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool { func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
diff := math.Abs(lrcDuration - targetDuration) diff := math.Abs(lrcDuration - targetDuration)
return diff <= durationToleranceSec return diff <= durationToleranceSec
} }
// FetchLyricsAllSources fetches lyrics from multiple sources with caching and duration matching
// durationSec: track duration in seconds for matching, use 0 to skip duration matching // 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) {
// Check cache first // Check cache first
@@ -396,7 +377,6 @@ func msToLRCTimestamp(ms int64) string {
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
} }
// convertToLRC converts lyrics to LRC format string (without metadata headers)
// Use convertToLRCWithMetadata for full LRC with headers // Use convertToLRCWithMetadata for full LRC with headers
// Kept for potential future use // Kept for potential future use
// func convertToLRC(lyrics *LyricsResponse) string { // func convertToLRC(lyrics *LyricsResponse) string {
@@ -423,8 +403,6 @@ func msToLRCTimestamp(ms int64) string {
// return builder.String() // return builder.String()
// } // }
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
// Includes [ti:], [ar:], [by:] headers
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 ""
@@ -432,13 +410,11 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
var builder strings.Builder var builder strings.Builder
// Add metadata headers
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName)) builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName)) builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
builder.WriteString("[by:SpotiFLAC-Mobile]\n") builder.WriteString("[by:SpotiFLAC-Mobile]\n")
builder.WriteString("\n") builder.WriteString("\n")
// Add lyrics lines
if lyrics.SyncType == "LINE_SYNCED" { if lyrics.SyncType == "LINE_SYNCED" {
for _, line := range lyrics.Lines { for _, line := range lyrics.Lines {
if line.Words == "" { if line.Words == "" {
@@ -488,24 +464,17 @@ func simplifyTrackName(name string) string {
return strings.TrimSpace(result) return strings.TrimSpace(result)
} }
// SaveLRCFile saves lyrics as a .lrc file next to the audio file
// audioFilePath: path to the audio file (e.g., /path/to/song.flac)
// lrcContent: the LRC format lyrics content
// Returns the path to the saved .lrc file, or error
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) { func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
if lrcContent == "" { if lrcContent == "" {
return "", fmt.Errorf("empty LRC content") return "", fmt.Errorf("empty LRC content")
} }
// Get the directory and base name without extension
dir := filepath.Dir(audioFilePath) dir := filepath.Dir(audioFilePath)
ext := filepath.Ext(audioFilePath) ext := filepath.Ext(audioFilePath)
baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext) baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext)
// Create the .lrc file path
lrcFilePath := filepath.Join(dir, baseName+".lrc") lrcFilePath := filepath.Join(dir, baseName+".lrc")
// Write the LRC content to the file
if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil { if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil {
return "", fmt.Errorf("failed to write LRC file: %w", err) return "", fmt.Errorf("failed to write LRC file: %w", err)
} }
+8 -23
View File
@@ -11,7 +11,6 @@ import (
"github.com/go-flac/go-flac" "github.com/go-flac/go-flac"
) )
// Metadata represents track metadata for embedding
type Metadata struct { type Metadata struct {
Title string Title string
Artist string Artist string
@@ -24,12 +23,11 @@ type Metadata struct {
ISRC string ISRC string
Description string Description string
Lyrics string Lyrics string
Genre string // Music genre (e.g., "Rock", "Pop", "Electronic") Genre string
Label string // Record label (ORGANIZATION tag in Vorbis) Label string
Copyright string // Copyright information Copyright string
} }
// EmbedMetadata embeds metadata into a FLAC file
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -138,8 +136,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
return f.Save(filePath) return f.Save(filePath)
} }
// EmbedMetadataWithCoverData embeds metadata into a FLAC file with cover data as bytes
// This avoids file permission issues on Android by not requiring a temp file
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error { func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -337,7 +333,6 @@ func fileExists(path string) bool {
return err == nil return err == nil
} }
// EmbedLyrics embeds lyrics into a FLAC file as a separate operation
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 {
@@ -375,11 +370,9 @@ func EmbedLyrics(filePath string, lyrics string) error {
return f.Save(filePath) return f.Save(filePath)
} }
// EmbedGenreLabel embeds genre and label into a FLAC file as a separate operation
// This is used for extension downloads where the file is already downloaded
func EmbedGenreLabel(filePath string, genre, label string) error { func EmbedGenreLabel(filePath string, genre, label string) error {
if genre == "" && label == "" { if genre == "" && label == "" {
return nil // Nothing to embed return nil
} }
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
@@ -451,16 +444,12 @@ func ExtractLyrics(filePath string) (string, error) {
return "", fmt.Errorf("no lyrics found in file") return "", fmt.Errorf("no lyrics found in file")
} }
// AudioQuality represents audio quality info from a FLAC file
type AudioQuality struct { type AudioQuality struct {
BitDepth int `json:"bit_depth"` BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"` SampleRate int `json:"sample_rate"`
TotalSamples int64 `json:"total_samples"` TotalSamples int64 `json:"total_samples"`
} }
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
// For M4A files, it delegates to GetM4AQuality
func GetAudioQuality(filePath string) (AudioQuality, error) { func GetAudioQuality(filePath string) (AudioQuality, error) {
file, err := os.Open(filePath) file, err := os.Open(filePath)
if err != nil { if err != nil {
@@ -597,7 +586,6 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
return nil return nil
} }
// findAtom finds an atom by name starting from offset
func findAtom(data []byte, name string, offset int) int { func findAtom(data []byte, name string, offset int) int {
for i := offset; i < len(data)-8; { for i := offset; i < len(data)-8; {
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3])) size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
@@ -689,7 +677,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
return metaAtom return metaAtom
} }
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
func buildTextAtom(name, value string) []byte { func buildTextAtom(name, value string) []byte {
valueBytes := []byte(value) valueBytes := []byte(value)
@@ -741,7 +728,6 @@ func buildTrackNumberAtom(track, total int) []byte {
return atom return atom
} }
// buildDiscNumberAtom builds disk atom
func buildDiscNumberAtom(disc, total int) []byte { func buildDiscNumberAtom(disc, total int) []byte {
dataAtom := []byte{ dataAtom := []byte{
0, 0, 0, 22, // size 0, 0, 0, 22, // size
@@ -767,9 +753,9 @@ func buildDiscNumberAtom(disc, total int) []byte {
// buildCoverAtom builds covr atom with image data // buildCoverAtom builds covr atom with image data
func buildCoverAtom(coverData []byte) []byte { func buildCoverAtom(coverData []byte) []byte {
imageType := byte(13) // default JPEG imageType := byte(13)
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' { if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
imageType = 14 // PNG imageType = 14
} }
dataSize := 16 + len(coverData) dataSize := 16 + len(coverData)
@@ -779,8 +765,8 @@ func buildCoverAtom(coverData []byte) []byte {
dataAtom[2] = byte(dataSize >> 8) dataAtom[2] = byte(dataSize >> 8)
dataAtom[3] = byte(dataSize) dataAtom[3] = byte(dataSize)
dataAtom = append(dataAtom, []byte("data")...) dataAtom = append(dataAtom, []byte("data")...)
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG dataAtom = append(dataAtom, 0, 0, 0, imageType)
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale dataAtom = append(dataAtom, 0, 0, 0, 0)
dataAtom = append(dataAtom, coverData...) dataAtom = append(dataAtom, coverData...)
atomSize := 8 + len(dataAtom) atomSize := 8 + len(dataAtom)
@@ -795,7 +781,6 @@ func buildCoverAtom(coverData []byte) []byte {
return atom return atom
} }
// GetM4AQuality reads audio quality from M4A file
func GetM4AQuality(filePath string) (AudioQuality, error) { func GetM4AQuality(filePath string) (AudioQuality, error) {
data, err := os.ReadFile(filePath) data, err := os.ReadFile(filePath)
if err != nil { if err != nil {
+2 -38
View File
@@ -6,11 +6,6 @@ import (
"time" "time"
) )
// ========================================
// ISRC to Track ID Cache
// ========================================
// TrackIDCacheEntry holds cached track ID with metadata
type TrackIDCacheEntry struct { type TrackIDCacheEntry struct {
TidalTrackID int64 TidalTrackID int64
QobuzTrackID int64 QobuzTrackID int64
@@ -18,7 +13,6 @@ type TrackIDCacheEntry struct {
ExpiresAt time.Time ExpiresAt time.Time
} }
// TrackIDCache caches ISRC to track ID mappings
type TrackIDCache struct { type TrackIDCache struct {
cache map[string]*TrackIDCacheEntry cache map[string]*TrackIDCacheEntry
mu sync.RWMutex mu sync.RWMutex
@@ -30,7 +24,6 @@ var (
trackIDCacheOnce sync.Once trackIDCacheOnce sync.Once
) )
// GetTrackIDCache returns the global track ID cache
func GetTrackIDCache() *TrackIDCache { func GetTrackIDCache() *TrackIDCache {
trackIDCacheOnce.Do(func() { trackIDCacheOnce.Do(func() {
globalTrackIDCache = &TrackIDCache{ globalTrackIDCache = &TrackIDCache{
@@ -41,7 +34,6 @@ func GetTrackIDCache() *TrackIDCache {
return globalTrackIDCache return globalTrackIDCache
} }
// Get retrieves a cached entry by ISRC
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry { func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -53,7 +45,6 @@ func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
return entry return entry
} }
// SetTidal caches Tidal track ID for an ISRC
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) { func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -67,7 +58,6 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
entry.ExpiresAt = time.Now().Add(c.ttl) entry.ExpiresAt = time.Now().Add(c.ttl)
} }
// SetQobuz caches Qobuz track ID for an ISRC
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -81,7 +71,6 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
entry.ExpiresAt = time.Now().Add(c.ttl) entry.ExpiresAt = time.Now().Add(c.ttl)
} }
// SetAmazon caches Amazon track ID for an ISRC
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) { func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -95,24 +84,18 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
entry.ExpiresAt = time.Now().Add(c.ttl) entry.ExpiresAt = time.Now().Add(c.ttl)
} }
// Clear removes all cached entries
func (c *TrackIDCache) Clear() { func (c *TrackIDCache) Clear() {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.cache = make(map[string]*TrackIDCacheEntry) c.cache = make(map[string]*TrackIDCacheEntry)
} }
// Size returns the number of cached entries
func (c *TrackIDCache) Size() int { func (c *TrackIDCache) Size() int {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
return len(c.cache) return len(c.cache)
} }
// ========================================
// Parallel Download Helper
// ========================================
// ParallelDownloadResult holds results from parallel operations // ParallelDownloadResult holds results from parallel operations
type ParallelDownloadResult struct { type ParallelDownloadResult struct {
CoverData []byte CoverData []byte
@@ -122,9 +105,6 @@ type ParallelDownloadResult struct {
LyricsErr error LyricsErr error
} }
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
// This runs while the main audio download is happening
// durationMs: track duration in milliseconds for lyrics matching
func FetchCoverAndLyricsParallel( func FetchCoverAndLyricsParallel(
coverURL string, coverURL string,
maxQualityCover bool, maxQualityCover bool,
@@ -153,7 +133,6 @@ func FetchCoverAndLyricsParallel(
}() }()
} }
// Fetch lyrics in parallel
if embedLyrics { if embedLyrics {
wg.Add(1) wg.Add(1)
go func() { go func() {
@@ -180,11 +159,6 @@ func FetchCoverAndLyricsParallel(
return result return result
} }
// ========================================
// Pre-warm Cache for Album/Playlist
// ========================================
// PreWarmCacheRequest represents a track to pre-warm cache for
type PreWarmCacheRequest struct { type PreWarmCacheRequest struct {
ISRC string ISRC string
TrackName string TrackName string
@@ -193,8 +167,6 @@ type PreWarmCacheRequest struct {
Service string // "tidal", "qobuz", "amazon" Service string // "tidal", "qobuz", "amazon"
} }
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
// This runs in background while user is viewing the track list
func PreWarmTrackCache(requests []PreWarmCacheRequest) { func PreWarmTrackCache(requests []PreWarmCacheRequest) {
if len(requests) == 0 { if len(requests) == 0 {
return return
@@ -214,8 +186,8 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
wg.Add(1) wg.Add(1)
go func(r PreWarmCacheRequest) { go func(r PreWarmCacheRequest) {
defer wg.Done() defer wg.Done()
semaphore <- struct{}{} // Acquire semaphore <- struct{}{}
defer func() { <-semaphore }() // Release defer func() { <-semaphore }()
switch r.Service { switch r.Service {
case "tidal": case "tidal":
@@ -259,12 +231,6 @@ func preWarmAmazonCache(isrc, spotifyID string) {
} }
} }
// ========================================
// Exported Functions for Flutter
// ========================================
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
func PreWarmCache(tracksJSON string) error { func PreWarmCache(tracksJSON string) error {
var requests []PreWarmCacheRequest var requests []PreWarmCacheRequest
@@ -272,13 +238,11 @@ func PreWarmCache(tracksJSON string) error {
return nil return nil
} }
// ClearTrackCache clears the track ID cache
func ClearTrackCache() { func ClearTrackCache() {
GetTrackIDCache().Clear() GetTrackIDCache().Clear()
fmt.Println("[Cache] Track ID cache cleared") fmt.Println("[Cache] Track ID cache cleared")
} }
// GetCacheSize returns the current cache size
func GetCacheSize() int { func GetCacheSize() int {
return GetTrackIDCache().Size() return GetTrackIDCache().Size()
} }
+4 -20
View File
@@ -6,8 +6,6 @@ import (
"time" "time"
) )
// DownloadProgress represents current download progress
// Now unified - returns data from multi-progress system
type DownloadProgress struct { type DownloadProgress struct {
CurrentFile string `json:"current_file"` CurrentFile string `json:"current_file"`
Progress float64 `json:"progress"` Progress float64 `json:"progress"`
@@ -15,21 +13,19 @@ type DownloadProgress struct {
BytesTotal int64 `json:"bytes_total"` BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"` BytesReceived int64 `json:"bytes_received"`
IsDownloading bool `json:"is_downloading"` IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed" Status string `json:"status"`
} }
// ItemProgress represents progress for a single download item
type ItemProgress struct { type ItemProgress struct {
ItemID string `json:"item_id"` ItemID string `json:"item_id"`
BytesTotal int64 `json:"bytes_total"` BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"` BytesReceived int64 `json:"bytes_received"`
Progress float64 `json:"progress"` // 0.0 to 1.0 Progress float64 `json:"progress"`
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s SpeedMBps float64 `json:"speed_mbps"`
IsDownloading bool `json:"is_downloading"` IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed" Status string `json:"status"`
} }
// MultiProgress holds progress for multiple concurrent downloads
type MultiProgress struct { type MultiProgress struct {
Items map[string]*ItemProgress `json:"items"` Items map[string]*ItemProgress `json:"items"`
} }
@@ -38,12 +34,10 @@ var (
downloadDir string downloadDir string
downloadDirMu sync.RWMutex downloadDirMu sync.RWMutex
// Multi-download progress tracking (unified system)
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)} multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
multiMu sync.RWMutex multiMu sync.RWMutex
) )
// getProgress returns current download progress from multi-progress system
func getProgress() DownloadProgress { func getProgress() DownloadProgress {
multiMu.RLock() multiMu.RLock()
defer multiMu.RUnlock() defer multiMu.RUnlock()
@@ -62,7 +56,6 @@ func getProgress() DownloadProgress {
return DownloadProgress{} return DownloadProgress{}
} }
// GetMultiProgress returns progress for all active downloads as JSON
func GetMultiProgress() string { func GetMultiProgress() string {
multiMu.RLock() multiMu.RLock()
defer multiMu.RUnlock() defer multiMu.RUnlock()
@@ -74,7 +67,6 @@ func GetMultiProgress() string {
return string(jsonBytes) return string(jsonBytes)
} }
// GetItemProgress returns progress for a specific item as JSON
func GetItemProgress(itemID string) string { func GetItemProgress(itemID string) string {
multiMu.RLock() multiMu.RLock()
defer multiMu.RUnlock() defer multiMu.RUnlock()
@@ -201,14 +193,6 @@ func setDownloadDir(path string) error {
return nil return nil
} }
// getDownloadDir returns the default download directory
// Kept for potential future use
// func getDownloadDir() string {
// downloadDirMu.RLock()
// defer downloadDirMu.RUnlock()
// return downloadDir
// }
// ItemProgressWriter wraps io.Writer to track download progress for a specific item // 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) }
+1 -41
View File
@@ -17,7 +17,6 @@ import (
"time" "time"
) )
// QobuzDownloader handles Qobuz downloads
type QobuzDownloader struct { type QobuzDownloader struct {
client *http.Client client *http.Client
appID string appID string
@@ -29,7 +28,6 @@ var (
qobuzDownloaderOnce sync.Once qobuzDownloaderOnce sync.Once
) )
// QobuzTrack represents a Qobuz track
type QobuzTrack struct { type QobuzTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
@@ -50,7 +48,6 @@ type QobuzTrack struct {
} `json:"performer"` } `json:"performer"`
} }
// qobuzArtistsMatch checks if the artist names are similar enough
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist))
@@ -93,9 +90,7 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
return false return false
} }
// qobuzSplitArtists splits artist string by common separators
func qobuzSplitArtists(artists string) []string { func qobuzSplitArtists(artists string) []string {
// Replace common separators with a standard one
normalized := artists normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|") normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|") normalized = strings.ReplaceAll(normalized, " feat ", "|")
@@ -154,7 +149,6 @@ func qobuzSameWordsUnordered(a, b string) bool {
return true return true
} }
// qobuzTitlesMatch checks if track titles are similar enough
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { 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))
@@ -164,12 +158,10 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
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
} }
// Clean BOTH titles and compare (removes suffixes like remaster, remix, etc)
cleanExpected := qobuzCleanTitle(normExpected) cleanExpected := qobuzCleanTitle(normExpected)
cleanFound := qobuzCleanTitle(normFound) cleanFound := qobuzCleanTitle(normFound)
@@ -177,14 +169,12 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// Check if cleaned versions contain each other
if cleanExpected != "" && cleanFound != "" { if cleanExpected != "" && cleanFound != "" {
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) { if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
return true return true
} }
} }
// Extract core title (before any parentheses/brackets)
coreExpected := qobuzExtractCoreTitle(normExpected) coreExpected := qobuzExtractCoreTitle(normExpected)
coreFound := qobuzExtractCoreTitle(normFound) coreFound := qobuzExtractCoreTitle(normFound)
@@ -225,19 +215,15 @@ func qobuzExtractCoreTitle(title string) string {
return strings.TrimSpace(title[:cutIdx]) return strings.TrimSpace(title[:cutIdx])
} }
// qobuzCleanTitle removes common suffixes from track titles for comparison
func qobuzCleanTitle(title string) string { func qobuzCleanTitle(title string) string {
cleaned := title cleaned := title
// Remove content in parentheses/brackets that are version indicators
// This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)"
versionPatterns := []string{ versionPatterns := []string{
"remaster", "remastered", "deluxe", "bonus", "single", "remaster", "remastered", "deluxe", "bonus", "single",
"album version", "radio edit", "original mix", "extended", "album version", "radio edit", "original mix", "extended",
"club mix", "remix", "live", "acoustic", "demo", "club mix", "remix", "live", "acoustic", "demo",
} }
// Remove parenthetical content if it contains version indicators
for { for {
startParen := strings.LastIndex(cleaned, "(") startParen := strings.LastIndex(cleaned, "(")
endParen := strings.LastIndex(cleaned, ")") endParen := strings.LastIndex(cleaned, ")")
@@ -258,7 +244,6 @@ func qobuzCleanTitle(title string) string {
break break
} }
// Same for brackets
for { for {
startBracket := strings.LastIndex(cleaned, "[") startBracket := strings.LastIndex(cleaned, "[")
endBracket := strings.LastIndex(cleaned, "]") endBracket := strings.LastIndex(cleaned, "]")
@@ -279,7 +264,6 @@ func qobuzCleanTitle(title string) string {
break break
} }
// Remove trailing " - version" patterns
dashPatterns := []string{ dashPatterns := []string{
" - remaster", " - remastered", " - single version", " - radio edit", " - remaster", " - remastered", " - single version", " - radio edit",
" - live", " - acoustic", " - demo", " - remix", " - live", " - acoustic", " - demo", " - remix",
@@ -290,7 +274,6 @@ func qobuzCleanTitle(title string) string {
} }
} }
// Remove multiple spaces
for strings.Contains(cleaned, " ") { for strings.Contains(cleaned, " ") {
cleaned = strings.ReplaceAll(cleaned, " ", " ") cleaned = strings.ReplaceAll(cleaned, " ", " ")
} }
@@ -350,7 +333,6 @@ func containsQueryQobuz(queries []string, query string) bool {
return false return false
} }
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
func NewQobuzDownloader() *QobuzDownloader { func NewQobuzDownloader() *QobuzDownloader {
qobuzDownloaderOnce.Do(func() { qobuzDownloaderOnce.Do(func() {
globalQobuzDownloader = &QobuzDownloader{ globalQobuzDownloader = &QobuzDownloader{
@@ -361,7 +343,6 @@ func NewQobuzDownloader() *QobuzDownloader {
return globalQobuzDownloader return globalQobuzDownloader
} }
// GetTrackByID fetches track info directly by Qobuz track ID
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
// Qobuz API: /track/get?track_id=XXX // Qobuz API: /track/get?track_id=XXX
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
@@ -412,7 +393,6 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
return apis return apis
} }
// SearchTrackByISRC searches for a track by ISRC
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
@@ -455,7 +435,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)
} }
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
// expectedDurationSec is the expected duration in seconds (0 to skip verification) // expectedDurationSec is the expected duration in seconds (0 to skip verification)
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc) GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
@@ -500,7 +479,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches)) GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
if len(isrcMatches) > 0 { if len(isrcMatches) > 0 {
// Verify duration if provided
if expectedDurationSec > 0 { if expectedDurationSec > 0 {
var durationVerifiedMatches []*QobuzTrack var durationVerifiedMatches []*QobuzTrack
for _, track := range isrcMatches { for _, track := range isrcMatches {
@@ -508,7 +486,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
if durationDiff < 0 { if durationDiff < 0 {
durationDiff = -durationDiff durationDiff = -durationDiff
} }
// Allow 10 seconds tolerance
if durationDiff <= 10 { if durationDiff <= 10 {
durationVerifiedMatches = append(durationVerifiedMatches, track) durationVerifiedMatches = append(durationVerifiedMatches, track)
} }
@@ -520,14 +497,12 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
return durationVerifiedMatches[0], nil return durationVerifiedMatches[0], nil
} }
// ISRC matches but duration doesn't
GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
isrc, expectedDurationSec, isrcMatches[0].Duration) isrc, expectedDurationSec, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)", return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
expectedDurationSec, isrcMatches[0].Duration) expectedDurationSec, isrcMatches[0].Duration)
} }
// No duration to verify, return first match
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil return isrcMatches[0], nil
} }
@@ -539,17 +514,14 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
} }
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
return q.SearchTrackByISRCWithDuration(isrc, 0) return q.SearchTrackByISRCWithDuration(isrc, 0)
} }
// SearchTrackByMetadata searches for a track using artist name and track name
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0) return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
} }
// SearchTrackByMetadataWithDuration searches for a track with duration verification
// Now includes romaji conversion for Japanese text (same as Tidal) // Now includes romaji conversion for Japanese text (same as Tidal)
// Also includes title verification to prevent wrong song downloads // 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) {
@@ -688,7 +660,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
} }
if len(durationMatches) > 0 { if len(durationMatches) > 0 {
// Return best quality among duration matches
for _, track := range durationMatches { for _, track := range durationMatches {
if track.MaximumBitDepth >= 24 { if track.MaximumBitDepth >= 24 {
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n", GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
@@ -701,7 +672,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return durationMatches[0], nil return durationMatches[0], nil
} }
// No duration match found
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)
} }
@@ -731,8 +701,6 @@ type qobuzAPIResult struct {
duration time.Duration duration time.Duration
} }
// getQobuzDownloadURLParallel requests download URL from all APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) { func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
if len(apis) == 0 { if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available") return "", "", fmt.Errorf("no APIs available")
@@ -839,8 +807,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors) return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
} }
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
apis := q.GetAvailableAPIs() apis := q.GetAvailableAPIs()
if len(apis) == 0 { if len(apis) == 0 {
@@ -938,7 +904,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return nil return nil
} }
// QobuzDownloadResult contains download result with quality info
type QobuzDownloadResult struct { type QobuzDownloadResult struct {
FilePath string FilePath string
BitDepth int BitDepth int
@@ -952,7 +917,6 @@ type QobuzDownloadResult struct {
ISRC string ISRC string
} }
// downloadFromQobuz downloads a track using the request parameters
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
downloader := NewQobuzDownloader() downloader := NewQobuzDownloader()
@@ -1135,15 +1099,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
fmt.Printf("Warning: failed to embed metadata: %v\n", err) fmt.Printf("Warning: failed to embed metadata: %v\n", err)
} }
// Handle lyrics based on LyricsMode setting
// Mode: "embed" (default), "external" (.lrc file), "both"
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"
} }
// Save external .lrc file if mode is "external" or "both"
if lyricsMode == "external" || lyricsMode == "both" { if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Qobuz] Saving external LRC file...\n") GoLog("[Qobuz] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
@@ -1153,7 +1114,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
} }
} }
// Embed lyrics if mode is "embed" or "both"
if lyricsMode == "embed" || lyricsMode == "both" { if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
-8
View File
@@ -5,7 +5,6 @@ import (
"time" "time"
) )
// RateLimiter implements a sliding window rate limiter
type RateLimiter struct { type RateLimiter struct {
mu sync.Mutex mu sync.Mutex
maxRequests int maxRequests int
@@ -13,7 +12,6 @@ type RateLimiter struct {
timestamps []time.Time timestamps []time.Time
} }
// NewRateLimiter creates a new rate limiter with specified max requests per window
func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter { func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
return &RateLimiter{ return &RateLimiter{
maxRequests: maxRequests, maxRequests: maxRequests,
@@ -22,8 +20,6 @@ func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
} }
} }
// WaitForSlot blocks until a request is allowed under the rate limit
// Returns immediately if under the limit, otherwise waits until a slot is available
func (r *RateLimiter) WaitForSlot() { func (r *RateLimiter) WaitForSlot() {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -70,8 +66,6 @@ func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
} }
} }
// TryAcquire attempts to acquire a slot without blocking
// Returns true if successful, false if rate limit would be exceeded
func (r *RateLimiter) TryAcquire() bool { func (r *RateLimiter) TryAcquire() bool {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -87,7 +81,6 @@ func (r *RateLimiter) TryAcquire() bool {
return false return false
} }
// Available returns the number of requests available in the current window
func (r *RateLimiter) Available() int { func (r *RateLimiter) Available() int {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -99,7 +92,6 @@ func (r *RateLimiter) Available() int {
// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10) // Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10)
var songLinkRateLimiter = NewRateLimiter(9, time.Minute) var songLinkRateLimiter = NewRateLimiter(9, time.Minute)
// GetSongLinkRateLimiter returns the global SongLink rate limiter
func GetSongLinkRateLimiter() *RateLimiter { func GetSongLinkRateLimiter() *RateLimiter {
return songLinkRateLimiter return songLinkRateLimiter
} }
-11
View File
@@ -5,7 +5,6 @@ import (
"unicode" "unicode"
) )
// Hiragana to Romaji mapping
var hiraganaToRomaji = map[rune]string{ var hiraganaToRomaji = map[rune]string{
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o", 'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko", 'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
@@ -30,7 +29,6 @@ var hiraganaToRomaji = map[rune]string{
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o", 'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
} }
// Katakana to Romaji mapping
var katakanaToRomaji = map[rune]string{ var katakanaToRomaji = map[rune]string{
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o", 'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko", 'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
@@ -58,7 +56,6 @@ var katakanaToRomaji = map[rune]string{
'ヴ': "vu", 'ヴ': "vu",
} }
// Combination mappings for きゃ, しゃ, etc.
var combinationHiragana = map[string]string{ var combinationHiragana = map[string]string{
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo", "きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
"しゃ": "sha", "しゅ": "shu", "しょ": "sho", "しゃ": "sha", "しゅ": "shu", "しょ": "sho",
@@ -91,7 +88,6 @@ var combinationKatakana = map[string]string{
"ウィ": "wi", "ウェ": "we", "ウォ": "wo", "ウィ": "wi", "ウェ": "we", "ウォ": "wo",
} }
// ContainsJapanese checks if a string contains Japanese characters
func ContainsJapanese(s string) bool { func ContainsJapanese(s string) bool {
for _, r := range s { for _, r := range s {
if isHiragana(r) || isKatakana(r) || isKanji(r) { if isHiragana(r) || isKatakana(r) || isKanji(r) {
@@ -114,8 +110,6 @@ func isKanji(r rune) bool {
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A (r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
} }
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
func JapaneseToRomaji(text string) string { func JapaneseToRomaji(text string) string {
if !ContainsJapanese(text) { if !ContainsJapanese(text) {
return text return text
@@ -175,8 +169,6 @@ func JapaneseToRomaji(text string) string {
return result.String() return result.String()
} }
// BuildSearchQuery creates a search query from track name and artist
// Converts Japanese to romaji if present
func BuildSearchQuery(trackName, artistName string) string { func BuildSearchQuery(trackName, artistName string) string {
// Convert Japanese to romaji // Convert Japanese to romaji
trackRomaji := JapaneseToRomaji(trackName) trackRomaji := JapaneseToRomaji(trackName)
@@ -189,7 +181,6 @@ func BuildSearchQuery(trackName, artistName string) string {
return strings.TrimSpace(artistClean + " " + trackClean) return strings.TrimSpace(artistClean + " " + trackClean)
} }
// cleanSearchQuery removes special characters that might interfere with search
func cleanSearchQuery(s string) string { func cleanSearchQuery(s string) string {
var result strings.Builder var result strings.Builder
for _, r := range s { for _, r := range s {
@@ -202,8 +193,6 @@ func cleanSearchQuery(s string) string {
return strings.TrimSpace(result.String()) return strings.TrimSpace(result.String())
} }
// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
// This is useful for creating search queries that work better with Tidal's search
func CleanToASCII(s string) string { func CleanToASCII(s string) string {
var result strings.Builder var result strings.Builder
for _, r := range s { for _, r := range s {
-15
View File
@@ -11,12 +11,10 @@ import (
"time" "time"
) )
// SongLinkClient handles song.link API interactions
type SongLinkClient struct { type SongLinkClient struct {
client *http.Client client *http.Client
} }
// TrackAvailability represents track availability on different platforms
type TrackAvailability struct { type TrackAvailability struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"` Tidal bool `json:"tidal"`
@@ -35,7 +33,6 @@ var (
songLinkClientOnce sync.Once songLinkClientOnce sync.Once
) )
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
func NewSongLinkClient() *SongLinkClient { func NewSongLinkClient() *SongLinkClient {
songLinkClientOnce.Do(func() { songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{ globalSongLinkClient = &SongLinkClient{
@@ -45,7 +42,6 @@ func NewSongLinkClient() *SongLinkClient {
return globalSongLinkClient return globalSongLinkClient
} }
// CheckTrackAvailability checks track availability on streaming platforms
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
if spotifyTrackID == "" { if spotifyTrackID == "" {
return nil, fmt.Errorf("spotify track ID is empty") return nil, fmt.Errorf("spotify track ID is empty")
@@ -126,7 +122,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
return availability, nil return availability, nil
} }
// GetStreamingURLs gets streaming URLs for a Spotify track
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) { func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "") availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil { if err != nil {
@@ -191,7 +186,6 @@ func extractDeezerIDFromURL(deezerURL string) string {
return "" return ""
} }
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) { func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "") availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil { if err != nil {
@@ -213,7 +207,6 @@ type AlbumAvailability struct {
DeezerID string `json:"deezer_id,omitempty"` DeezerID string `json:"deezer_id,omitempty"`
} }
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
// Use global rate limiter // Use global rate limiter
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
@@ -283,11 +276,6 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
return availability.DeezerID, nil return availability.DeezerID, nil
} }
// ========================================
// Deezer ID Support - Query SongLink using Deezer as source
// ========================================
// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source
// This is useful when we have Deezer metadata and want to find the track on other platforms // This is useful when we have Deezer metadata and want to find the track on other platforms
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) { func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
if deezerTrackID == "" { if deezerTrackID == "" {
@@ -374,7 +362,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
return availability, nil return availability, nil
} }
// CheckAvailabilityByPlatform checks track availability using any supported platform
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc. // platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
// entityType: "song" or "album" // entityType: "song" or "album"
// entityID: the ID on that platform // entityID: the ID on that platform
@@ -472,7 +459,6 @@ func extractSpotifyIDFromURL(spotifyURL string) string {
return "" return ""
} }
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) { func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil { if err != nil {
@@ -500,7 +486,6 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er
return availability.TidalURL, nil return availability.TidalURL, nil
} }
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) { func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil { if err != nil {
+11 -62
View File
@@ -24,7 +24,6 @@ const (
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums" artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
searchBaseURL = "https://api.spotify.com/v1/search" searchBaseURL = "https://api.spotify.com/v1/search"
// Cache TTL settings
artistCacheTTL = 10 * time.Minute artistCacheTTL = 10 * time.Minute
searchCacheTTL = 5 * time.Minute searchCacheTTL = 5 * time.Minute
albumCacheTTL = 10 * time.Minute albumCacheTTL = 10 * time.Minute
@@ -32,7 +31,6 @@ const (
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL") var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
// cacheEntry holds cached data with expiration
type cacheEntry struct { type cacheEntry struct {
data interface{} data interface{}
expiresAt time.Time expiresAt time.Time
@@ -42,26 +40,23 @@ func (e *cacheEntry) isExpired() bool {
return time.Now().After(e.expiresAt) return time.Now().After(e.expiresAt)
} }
// SpotifyMetadataClient handles Spotify API interactions
type SpotifyMetadataClient struct { type SpotifyMetadataClient struct {
httpClient *http.Client httpClient *http.Client
clientID string clientID string
clientSecret string clientSecret string
cachedToken string cachedToken string
tokenExpiresAt time.Time tokenExpiresAt time.Time
tokenMu sync.Mutex // Protects token cache for concurrent access tokenMu sync.Mutex
rng *rand.Rand rng *rand.Rand
rngMu sync.Mutex rngMu sync.Mutex
userAgent string userAgent string
// Caches to reduce API calls artistCache map[string]*cacheEntry
artistCache map[string]*cacheEntry // key: artistID searchCache map[string]*cacheEntry
searchCache map[string]*cacheEntry // key: query+type albumCache map[string]*cacheEntry
albumCache map[string]*cacheEntry // key: albumID
cacheMu sync.RWMutex cacheMu sync.RWMutex
} }
// Custom credentials storage (set from Flutter)
var ( var (
customClientID string customClientID string
customClientSecret string customClientSecret string
@@ -79,7 +74,6 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
customClientSecret = clientSecret customClientSecret = clientSecret
} }
// HasSpotifyCredentials checks if Spotify credentials are configured
func HasSpotifyCredentials() bool { func HasSpotifyCredentials() bool {
credentialsMu.RLock() credentialsMu.RLock()
defer credentialsMu.RUnlock() defer credentialsMu.RUnlock()
@@ -114,8 +108,6 @@ func getCredentials() (string, string, error) {
return "", "", ErrNoSpotifyCredentials return "", "", ErrNoSpotifyCredentials
} }
// NewSpotifyMetadataClient creates a new Spotify client
// Returns error if credentials are not configured
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) { func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
clientID, clientSecret, err := getCredentials() clientID, clientSecret, err := getCredentials()
if err != nil { if err != nil {
@@ -137,7 +129,6 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
return c, nil return c, nil
} }
// TrackMetadata represents track information
type TrackMetadata struct { type TrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"` SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"` Artists string `json:"artists"`
@@ -155,7 +146,6 @@ type TrackMetadata struct {
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
} }
// AlbumTrackMetadata holds per-track info for album/playlist
type AlbumTrackMetadata struct { type AlbumTrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"` SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"` Artists string `json:"artists"`
@@ -172,28 +162,25 @@ type AlbumTrackMetadata struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
AlbumID string `json:"album_id,omitempty"` AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"` AlbumURL string `json:"album_url,omitempty"`
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation AlbumType string `json:"album_type,omitempty"`
} }
// AlbumInfoMetadata holds album information
type AlbumInfoMetadata struct { type AlbumInfoMetadata struct {
TotalTracks int `json:"total_tracks"` TotalTracks int `json:"total_tracks"`
Name string `json:"name"` Name string `json:"name"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
Artists string `json:"artists"` Artists string `json:"artists"`
Images string `json:"images"` Images string `json:"images"`
Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated Genre string `json:"genre,omitempty"`
Label string `json:"label,omitempty"` // Record label name Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"` // Copyright information Copyright string `json:"copyright,omitempty"`
} }
// AlbumResponsePayload is the response for album requests
type AlbumResponsePayload struct { type AlbumResponsePayload struct {
AlbumInfo AlbumInfoMetadata `json:"album_info"` AlbumInfo AlbumInfoMetadata `json:"album_info"`
TrackList []AlbumTrackMetadata `json:"track_list"` TrackList []AlbumTrackMetadata `json:"track_list"`
} }
// PlaylistInfoMetadata holds playlist information
type PlaylistInfoMetadata struct { type PlaylistInfoMetadata struct {
Tracks struct { Tracks struct {
Total int `json:"total"` Total int `json:"total"`
@@ -205,13 +192,11 @@ type PlaylistInfoMetadata struct {
} `json:"owner"` } `json:"owner"`
} }
// PlaylistResponsePayload is the response for playlist requests
type PlaylistResponsePayload struct { type PlaylistResponsePayload struct {
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"` PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
TrackList []AlbumTrackMetadata `json:"track_list"` TrackList []AlbumTrackMetadata `json:"track_list"`
} }
// ArtistInfoMetadata holds artist information
type ArtistInfoMetadata struct { type ArtistInfoMetadata struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -220,7 +205,6 @@ type ArtistInfoMetadata struct {
Popularity int `json:"popularity"` Popularity int `json:"popularity"`
} }
// ArtistAlbumMetadata holds album info for artist discography
type ArtistAlbumMetadata struct { type ArtistAlbumMetadata struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -231,24 +215,20 @@ type ArtistAlbumMetadata struct {
Artists string `json:"artists"` Artists string `json:"artists"`
} }
// ArtistResponsePayload is the response for artist requests
type ArtistResponsePayload struct { type ArtistResponsePayload struct {
ArtistInfo ArtistInfoMetadata `json:"artist_info"` ArtistInfo ArtistInfoMetadata `json:"artist_info"`
Albums []ArtistAlbumMetadata `json:"albums"` Albums []ArtistAlbumMetadata `json:"albums"`
} }
// TrackResponse is the response for single track requests
type TrackResponse struct { type TrackResponse struct {
Track TrackMetadata `json:"track"` Track TrackMetadata `json:"track"`
} }
// SearchResult represents search results
type SearchResult struct { type SearchResult struct {
Tracks []TrackMetadata `json:"tracks"` Tracks []TrackMetadata `json:"tracks"`
Total int `json:"total"` Total int `json:"total"`
} }
// SearchArtistResult represents an artist in search results
type SearchArtistResult struct { type SearchArtistResult struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -257,7 +237,6 @@ type SearchArtistResult struct {
Popularity int `json:"popularity"` Popularity int `json:"popularity"`
} }
// SearchAllResult represents combined search results for tracks and artists
type SearchAllResult struct { type SearchAllResult struct {
Tracks []TrackMetadata `json:"tracks"` Tracks []TrackMetadata `json:"tracks"`
Artists []SearchArtistResult `json:"artists"` Artists []SearchArtistResult `json:"artists"`
@@ -274,7 +253,6 @@ type accessTokenResponse struct {
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
} }
// Internal API response types
type image struct { type image struct {
URL string `json:"url"` URL string `json:"url"`
} }
@@ -300,7 +278,7 @@ type albumSimplified struct {
Images []image `json:"images"` Images []image `json:"images"`
ExternalURL externalURL `json:"external_urls"` ExternalURL externalURL `json:"external_urls"`
Artists []artist `json:"artists"` Artists []artist `json:"artists"`
AlbumType string `json:"album_type"` // album, single, compilation AlbumType string `json:"album_type"`
} }
type trackFull struct { type trackFull struct {
@@ -315,7 +293,6 @@ type trackFull struct {
Artists []artist `json:"artists"` Artists []artist `json:"artists"`
} }
// GetFilteredData fetches and formats Spotify data
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) { func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
parsed, err := parseSpotifyURI(spotifyURL) parsed, err := parseSpotifyURI(spotifyURL)
if err != nil { if err != nil {
@@ -341,7 +318,6 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
} }
} }
// SearchTracks searches for tracks on Spotify
func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) { func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) {
token, err := c.getAccessToken(ctx) token, err := c.getAccessToken(ctx)
if err != nil { if err != nil {
@@ -388,7 +364,6 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
return result, nil return result, nil
} }
// SearchAll searches for tracks and artists on Spotify
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit) cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
@@ -510,7 +485,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
// Track item structure for pagination
type trackItem struct { type trackItem struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -546,11 +520,9 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
Images: albumImage, Images: albumImage,
} }
// Collect all tracks (including paginated)
allTrackItems := data.Tracks.Items allTrackItems := data.Tracks.Items
nextURL := data.Tracks.Next nextURL := data.Tracks.Next
// Fetch remaining tracks using pagination (no limit)
for nextURL != "" { for nextURL != "" {
var pageData struct { var pageData struct {
Items []trackItem `json:"items"` Items []trackItem `json:"items"`
@@ -572,7 +544,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
trackIDs[i] = item.ID trackIDs[i] = item.ID
} }
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token) isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems)) tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
@@ -612,10 +583,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
return result, nil return result, nil
} }
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel
// Similar to Deezer implementation for consistency
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string { func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
const maxParallelISRC = 10 // Max concurrent ISRC fetches const maxParallelISRC = 10
result := make(map[string]string) result := make(map[string]string)
var resultMu sync.Mutex var resultMu sync.Mutex
@@ -624,7 +593,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
return result return result
} }
// Use semaphore to limit concurrent requests
sem := make(chan struct{}, maxParallelISRC) sem := make(chan struct{}, maxParallelISRC)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -633,7 +601,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
go func(id string) { go func(id string) {
defer wg.Done() defer wg.Done()
// Acquire semaphore
select { select {
case sem <- struct{}{}: case sem <- struct{}{}:
defer func() { <-sem }() defer func() { <-sem }()
@@ -654,7 +621,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
} }
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) { func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
// First request to get playlist info and first batch of tracks
var data struct { var data struct {
Name string `json:"name"` Name string `json:"name"`
Images []image `json:"images"` Images []image `json:"images"`
@@ -680,10 +646,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
info.Owner.Name = data.Name info.Owner.Name = data.Name
info.Owner.Images = firstImageURL(data.Images) info.Owner.Images = firstImageURL(data.Images)
// Pre-allocate with expected capacity
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total) tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
// Add first batch of tracks
for _, item := range data.Tracks.Items { for _, item := range data.Tracks.Items {
if item.Track == nil { if item.Track == nil {
continue continue
@@ -707,7 +671,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
}) })
} }
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
nextURL := data.Tracks.Next nextURL := data.Tracks.Next
for nextURL != "" { for nextURL != "" {
@@ -719,7 +682,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
} }
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil { if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
// Log error but return what we have so far
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err) fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
break break
} }
@@ -766,7 +728,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
// Fetch artist info
var artistData struct { var artistData struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -789,7 +750,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
Popularity: artistData.Popularity, Popularity: artistData.Popularity,
} }
// Fetch artist albums (all types: album, single, compilation)
albums := make([]ArtistAlbumMetadata, 0) albums := make([]ArtistAlbumMetadata, 0)
offset := 0 offset := 0
limit := 50 limit := 50
@@ -829,13 +789,11 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
}) })
} }
// Check if there are more albums
if albumsData.Next == "" || len(albumsData.Items) < limit { if albumsData.Next == "" || len(albumsData.Items) < limit {
break break
} }
offset += limit offset += limit
// Safety limit to prevent infinite loops
if offset > 500 { if offset > 500 {
break break
} }
@@ -916,7 +874,6 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
return err return err
} }
// Set headers (same as PC version baseHeaders)
req.Header.Set("User-Agent", c.userAgent) req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Language", "en-US,en;q=0.9") req.Header.Set("Accept-Language", "en-US,en;q=0.9")
@@ -952,8 +909,7 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
c.rngMu.Lock() c.rngMu.Lock()
defer c.rngMu.Unlock() defer c.rngMu.Unlock()
// Use Mac User-Agent format (same as PC version) macMajor := c.rng.Intn(4) + 11
macMajor := c.rng.Intn(4) + 11 // 11-14
macMinor := c.rng.Intn(5) + 4 // 4-8 macMinor := c.rng.Intn(5) + 4 // 4-8
webkitMajor := c.rng.Intn(7) + 530 // 530-536 webkitMajor := c.rng.Intn(7) + 530 // 530-536
webkitMinor := c.rng.Intn(7) + 30 // 30-36 webkitMinor := c.rng.Intn(7) + 30 // 30-36
@@ -978,7 +934,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL return spotifyURI{}, errInvalidSpotifyURL
} }
// Handle spotify: URI format
if strings.HasPrefix(trimmed, "spotify:") { if strings.HasPrefix(trimmed, "spotify:") {
parts := strings.Split(trimmed, ":") parts := strings.Split(trimmed, ":")
if len(parts) == 3 { if len(parts) == 3 {
@@ -989,13 +944,11 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
} }
} }
// Handle URL format
parsed, err := url.Parse(trimmed) parsed, err := url.Parse(trimmed)
if err != nil { if err != nil {
return spotifyURI{}, err return spotifyURI{}, err
} }
// Handle embed.spotify.com URLs
if parsed.Host == "embed.spotify.com" { if parsed.Host == "embed.spotify.com" {
if parsed.RawQuery == "" { if parsed.RawQuery == "" {
return spotifyURI{}, errInvalidSpotifyURL return spotifyURI{}, errInvalidSpotifyURL
@@ -1008,7 +961,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return parseSpotifyURI(embedded) return parseSpotifyURI(embedded)
} }
// Handle plain ID (no scheme/host) - defaults to playlist
if parsed.Scheme == "" && parsed.Host == "" { if parsed.Scheme == "" && parsed.Host == "" {
id := strings.Trim(strings.TrimSpace(parsed.Path), "/") id := strings.Trim(strings.TrimSpace(parsed.Path), "/")
if id == "" { if id == "" {
@@ -1034,7 +986,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL return spotifyURI{}, errInvalidSpotifyURL
} }
// Skip intl- prefix if present
if strings.HasPrefix(parts[0], "intl-") { if strings.HasPrefix(parts[0], "intl-") {
parts = parts[1:] parts = parts[1:]
} }
@@ -1042,7 +993,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
return spotifyURI{}, errInvalidSpotifyURL return spotifyURI{}, errInvalidSpotifyURL
} }
// Handle standard URLs: /album/{id}, /track/{id}, /playlist/{id}, /artist/{id}
if len(parts) == 2 { if len(parts) == 2 {
switch parts[0] { switch parts[0] {
case "album", "track", "playlist", "artist": case "album", "track", "playlist", "artist":
@@ -1050,7 +1000,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
} }
} }
// Handle nested playlist URLs: /user/{user}/playlist/{id}
if len(parts) == 4 && parts[2] == "playlist" { if len(parts) == 4 && parts[2] == "playlist" {
return spotifyURI{Type: "playlist", ID: parts[3]}, nil return spotifyURI{Type: "playlist", ID: parts[3]}, nil
} }
+18 -158
View File
@@ -19,7 +19,6 @@ import (
"time" "time"
) )
// TidalDownloader handles Tidal downloads
type TidalDownloader struct { type TidalDownloader struct {
client *http.Client client *http.Client
clientID string clientID string
@@ -35,7 +34,6 @@ var (
tidalDownloaderOnce sync.Once tidalDownloaderOnce sync.Once
) )
// TidalTrack represents a Tidal track
type TidalTrack struct { type TidalTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
@@ -60,7 +58,6 @@ type TidalTrack struct {
} `json:"mediaMetadata"` } `json:"mediaMetadata"`
} }
// TidalAPIResponseV2 is the new API response format (version 2.0)
type TidalAPIResponseV2 struct { type TidalAPIResponseV2 struct {
Version string `json:"version"` Version string `json:"version"`
Data struct { Data struct {
@@ -76,7 +73,6 @@ type TidalAPIResponseV2 struct {
} `json:"data"` } `json:"data"`
} }
// TidalBTSManifest is the BTS (application/vnd.tidal.bts) manifest format
type TidalBTSManifest struct { type TidalBTSManifest struct {
MimeType string `json:"mimeType"` MimeType string `json:"mimeType"`
Codecs string `json:"codecs"` Codecs string `json:"codecs"`
@@ -84,7 +80,6 @@ type TidalBTSManifest struct {
URLs []string `json:"urls"` URLs []string `json:"urls"`
} }
// MPD represents DASH manifest structure
type MPD struct { type MPD struct {
XMLName xml.Name `xml:"MPD"` XMLName xml.Name `xml:"MPD"`
Period struct { Period struct {
@@ -105,7 +100,6 @@ type MPD struct {
} `xml:"Period"` } `xml:"Period"`
} }
// NewTidalDownloader creates a new Tidal downloader (returns singleton for token reuse)
func NewTidalDownloader() *TidalDownloader { func NewTidalDownloader() *TidalDownloader {
tidalDownloaderOnce.Do(func() { tidalDownloaderOnce.Do(func() {
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
@@ -150,7 +144,6 @@ func (t *TidalDownloader) GetAvailableAPIs() []string {
return apis return apis
} }
// GetAccessToken gets Tidal access token (with caching)
func (t *TidalDownloader) GetAccessToken() (string, error) { func (t *TidalDownloader) GetAccessToken() (string, error) {
t.tokenMu.Lock() t.tokenMu.Lock()
defer t.tokenMu.Unlock() defer t.tokenMu.Unlock()
@@ -199,7 +192,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
return result.AccessToken, nil return result.AccessToken, nil
} }
// GetTidalURLFromSpotify gets Tidal URL from Spotify track ID using SongLink
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -239,7 +231,6 @@ func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string,
return tidalLink.URL, nil return tidalLink.URL, nil
} }
// GetTrackIDFromURL extracts track ID from Tidal URL
func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
parts := strings.Split(tidalURL, "/track/") parts := strings.Split(tidalURL, "/track/")
if len(parts) < 2 { if len(parts) < 2 {
@@ -293,7 +284,6 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
return &trackInfo, nil return &trackInfo, nil
} }
// SearchTrackByISRC searches for a track by ISRC
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
token, err := t.GetAccessToken() token, err := t.GetAccessToken()
if err != nil { if err != nil {
@@ -341,30 +331,7 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, 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)
} }
// normalizeTitle normalizes a track title for comparison
// Kept for potential future use
// func normalizeTitle(title string) string {
// normalized := strings.ToLower(strings.TrimSpace(title))
//
// // Remove common suffixes in parentheses or brackets
// suffixPatterns := []string{
// " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
// " (bonus track)", " (single)", " (album version)", " (radio edit)",
// " [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
// }
// for _, suffix := range suffixPatterns {
// normalized = strings.TrimSuffix(normalized, suffix)
// }
//
// // Remove multiple spaces
// for strings.Contains(normalized, " ") {
// normalized = strings.ReplaceAll(normalized, " ", " ")
// }
//
// return normalized
// }
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
// Now includes romaji conversion for Japanese text (4 search strategies like PC) // Now includes romaji conversion for Japanese text (4 search strategies like PC)
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
token, err := t.GetAccessToken() token, err := t.GetAccessToken()
@@ -466,7 +433,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
if len(result.Items) > 0 { if len(result.Items) > 0 {
GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery) GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
// OPTIMIZATION: If ISRC provided, check for match immediately and return early
if spotifyISRC != "" { if spotifyISRC != "" {
for i := range result.Items { for i := range result.Items {
if result.Items[i].ISRC == spotifyISRC { if result.Items[i].ISRC == spotifyISRC {
@@ -592,7 +558,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
return bestMatch, nil return bestMatch, nil
} }
// containsQuery checks if a query already exists in the list
func containsQuery(queries []string, query string) bool { func containsQuery(queries []string, query string) bool {
for _, q := range queries { for _, q := range queries {
if q == query { if q == query {
@@ -602,7 +567,6 @@ func containsQuery(queries []string, query string) bool {
return false return false
} }
// SearchTrackByMetadata searches for a track using artist name and track name
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) { func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0) return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0)
} }
@@ -614,7 +578,6 @@ type TidalDownloadInfo struct {
SampleRate int SampleRate int
} }
// tidalAPIResult holds the result from a parallel API request
type tidalAPIResult struct { type tidalAPIResult struct {
apiURL string apiURL string
info TidalDownloadInfo info TidalDownloadInfo
@@ -622,9 +585,7 @@ type tidalAPIResult struct {
duration time.Duration duration time.Duration
} }
// getDownloadURLParallel requests download URL from all APIs in parallel
// Returns the first successful result (supports both v1 and v2 API formats) // Returns the first successful result (supports both v1 and v2 API formats)
// "Siapa cepat dia dapat" - first success wins
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) { func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
if len(apis) == 0 { if len(apis) == 0 {
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available") return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
@@ -671,8 +632,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
var v2Response TidalAPIResponseV2 var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks if v2Response.Data.AssetPresentation == "PREVIEW" {
if v2Response.Data.AssetPresentation == "PREVIEW" {
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)} resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
return return
} }
@@ -715,7 +675,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n", GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration) result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
// Don't return immediately - 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
@@ -736,8 +695,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors) return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
} }
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
// "Siapa cepat dia dapat" - first successful response wins
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) { func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
apis := t.GetAvailableAPIs() apis := t.GetAvailableAPIs()
if len(apis) == 0 { if len(apis) == 0 {
@@ -752,7 +709,6 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDo
return info, nil return info, nil
} }
// parseManifest parses Tidal manifest (supports both BTS and DASH formats)
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) { func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64) manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
if err != nil { if err != nil {
@@ -859,7 +815,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID) return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
} }
// Initialize item progress for direct downloads
if itemID != "" { if itemID != "" {
StartItemProgress(itemID) StartItemProgress(itemID)
defer CompleteItemProgress(itemID) defer CompleteItemProgress(itemID)
@@ -952,9 +907,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
if directURL != "" { if directURL != "" {
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))]) GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
// Note: Progress tracking is initialized by the caller (DownloadFile) if isDownloadCancelled(itemID) {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled return ErrDownloadCancelled
} }
@@ -1135,7 +1088,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return nil return nil
} }
// TidalDownloadResult contains download result with quality info
type TidalDownloadResult struct { type TidalDownloadResult struct {
FilePath string FilePath string
BitDepth int BitDepth int
@@ -1149,12 +1101,10 @@ type TidalDownloadResult struct {
ISRC string ISRC string
} }
// artistsMatch checks if the artist names are similar enough
func artistsMatch(spotifyArtist, tidalArtist string) bool { func artistsMatch(spotifyArtist, tidalArtist string) bool {
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist)) normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist)) normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
// Exact match
if normSpotify == normTidal { if normSpotify == normTidal {
return true return true
} }
@@ -1164,22 +1114,17 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return true return true
} }
// Split artists by common separators (comma, feat, ft., &, and)
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
spotifyArtists := splitArtists(normSpotify) spotifyArtists := splitArtists(normSpotify)
tidalArtists := splitArtists(normTidal) tidalArtists := splitArtists(normTidal)
// Check if ANY expected artist matches ANY found artist
for _, exp := range spotifyArtists { for _, exp := range spotifyArtists {
for _, fnd := range tidalArtists { for _, fnd := range tidalArtists {
if exp == fnd { if exp == fnd {
return true return true
} }
// Also check contains for partial matches
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) { if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
return true return true
} }
// Check same words different order
if sameWordsUnordered(exp, fnd) { if sameWordsUnordered(exp, fnd) {
GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd) GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
return true return true
@@ -1187,9 +1132,6 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
} }
} }
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
// Don't treat Latin Extended (Polish, French, etc.) as different script
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
spotifyLatin := isLatinScript(spotifyArtist) spotifyLatin := isLatinScript(spotifyArtist)
tidalLatin := isLatinScript(tidalArtist) tidalLatin := isLatinScript(tidalArtist)
if spotifyLatin != tidalLatin { if spotifyLatin != tidalLatin {
@@ -1200,9 +1142,7 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
return false return false
} }
// splitArtists splits artist string by common separators
func splitArtists(artists string) []string { func splitArtists(artists string) []string {
// Replace common separators with a standard one
normalized := artists normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|") normalized = strings.ReplaceAll(normalized, " feat. ", "|")
normalized = strings.ReplaceAll(normalized, " feat ", "|") normalized = strings.ReplaceAll(normalized, " feat ", "|")
@@ -1224,8 +1164,6 @@ func splitArtists(artists string) []string {
return result return result
} }
// sameWordsUnordered checks if two strings have the same words regardless of order
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
func sameWordsUnordered(a, b string) bool { func sameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a) wordsA := strings.Fields(a)
wordsB := strings.Fields(b) wordsB := strings.Fields(b)
@@ -1235,13 +1173,11 @@ func sameWordsUnordered(a, b string) bool {
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] {
@@ -1261,7 +1197,6 @@ func sameWordsUnordered(a, b string) bool {
return true return true
} }
// titlesMatch checks if track titles are similar enough
func titlesMatch(expectedTitle, foundTitle string) bool { 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))
@@ -1271,7 +1206,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
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
} }
@@ -1284,7 +1218,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return true return true
} }
// Check if cleaned versions contain each other
if cleanExpected != "" && cleanFound != "" { if cleanExpected != "" && cleanFound != "" {
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) { if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
return true return true
@@ -1299,7 +1232,6 @@ func titlesMatch(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 // Don't treat Latin Extended (Polish, French, etc.) as different script
expectedLatin := isLatinScript(expectedTitle) expectedLatin := isLatinScript(expectedTitle)
foundLatin := isLatinScript(foundTitle) foundLatin := isLatinScript(foundTitle)
@@ -1311,9 +1243,7 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
return false return false
} }
// extractCoreTitle extracts the main title before any parentheses or brackets
func extractCoreTitle(title string) string { func extractCoreTitle(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, " - ")
@@ -1332,18 +1262,15 @@ func extractCoreTitle(title string) string {
return strings.TrimSpace(title[:cutIdx]) return strings.TrimSpace(title[:cutIdx])
} }
// cleanTitle removes common suffixes from track titles for comparison
func cleanTitle(title string) string { func cleanTitle(title string) string {
cleaned := title cleaned := title
// Version indicators to remove from parentheses/brackets
versionPatterns := []string{ versionPatterns := []string{
"remaster", "remastered", "deluxe", "bonus", "single", "remaster", "remastered", "deluxe", "bonus", "single",
"album version", "radio edit", "original mix", "extended", "album version", "radio edit", "original mix", "extended",
"club mix", "remix", "live", "acoustic", "demo", "club mix", "remix", "live", "acoustic", "demo",
} }
// Remove parenthetical content if it contains version indicators
for { for {
startParen := strings.LastIndex(cleaned, "(") startParen := strings.LastIndex(cleaned, "(")
endParen := strings.LastIndex(cleaned, ")") endParen := strings.LastIndex(cleaned, ")")
@@ -1364,7 +1291,6 @@ func cleanTitle(title string) string {
break break
} }
// Same for brackets
for { for {
startBracket := strings.LastIndex(cleaned, "[") startBracket := strings.LastIndex(cleaned, "[")
endBracket := strings.LastIndex(cleaned, "]") endBracket := strings.LastIndex(cleaned, "]")
@@ -1385,7 +1311,6 @@ func cleanTitle(title string) string {
break break
} }
// Remove trailing " - version" patterns
dashPatterns := []string{ dashPatterns := []string{
" - remaster", " - remastered", " - single version", " - radio edit", " - remaster", " - remastered", " - single version", " - radio edit",
" - live", " - acoustic", " - demo", " - remix", " - live", " - acoustic", " - demo", " - remix",
@@ -1396,7 +1321,6 @@ func cleanTitle(title string) string {
} }
} }
// Remove multiple spaces
for strings.Contains(cleaned, " ") { for strings.Contains(cleaned, " ") {
cleaned = strings.ReplaceAll(cleaned, " ", " ") cleaned = strings.ReplaceAll(cleaned, " ", " ")
} }
@@ -1404,48 +1328,29 @@ func cleanTitle(title string) string {
return strings.TrimSpace(cleaned) return strings.TrimSpace(cleaned)
} }
// isLatinScript checks if a string is primarily Latin script
// Returns true for ASCII and Latin Extended characters (European languages)
// Returns false for CJK, Arabic, Cyrillic, etc.
func isLatinScript(s string) bool { func isLatinScript(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) {
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
} }
// isASCIIString checks if a string contains only ASCII characters
// Kept for potential future use
// func isASCIIString(s string) bool {
// for _, r := range s {
// if r > 127 {
// return false
// }
// }
// return true
// }
// downloadFromTidal downloads a track using the request parameters
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
downloader := NewTidalDownloader() downloader := NewTidalDownloader()
@@ -1453,16 +1358,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
} }
// Convert expected duration from ms to seconds
expectedDurationSec := req.DurationMS / 1000 expectedDurationSec := req.DurationMS / 1000
var track *TidalTrack var track *TidalTrack
var err error var err error
// STRATEGY 0: Use pre-fetched Tidal ID from Odesli enrichment (highest priority)
if req.TidalID != "" { if req.TidalID != "" {
GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID) GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID)
// Parse track ID (could be a number or extracted from URL)
var trackID int64 var trackID int64
if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
track, err = downloader.GetTrackInfoByID(trackID) track, err = downloader.GetTrackInfoByID(trackID)
@@ -1475,7 +1377,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, 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.TidalTrackID > 0 { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID) GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
@@ -1487,8 +1388,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
} }
// OPTIMIZED: Try ISRC search with metadata (search by name, filter by ISRC)
// Strategy 1: Search by metadata, match by ISRC (most accurate)
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC) GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC)
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec) track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
@@ -1510,7 +1409,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
} }
// Strategy 2: Try SongLink if we have Spotify ID
if track == nil && req.SpotifyID != "" { if track == nil && req.SpotifyID != "" {
GoLog("[Tidal] ISRC search failed, trying SongLink...\n") GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
var tidalURL string var tidalURL string
@@ -1545,13 +1443,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
track = nil track = nil
} }
// Verify duration if we have expected duration
if track != nil && expectedDurationSec > 0 { if track != nil && expectedDurationSec > 0 {
durationDiff := track.Duration - expectedDurationSec durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 { if durationDiff < 0 {
durationDiff = -durationDiff durationDiff = -durationDiff
} }
// Allow 3 seconds tolerance (same as PC version)
if durationDiff > 3 { if durationDiff > 3 {
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
expectedDurationSec, track.Duration) expectedDurationSec, track.Duration)
@@ -1563,11 +1459,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
} }
// Strategy 3: Search by metadata only (no ISRC requirement) - last resort
if track == nil { if track == nil {
GoLog("[Tidal] Trying metadata search as last resort...\n") GoLog("[Tidal] Trying metadata search as last resort...\n")
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec) track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
// Verify artist AND title for metadata search
if track != nil { if track != nil {
tidalArtist := track.Artist.Name tidalArtist := track.Artist.Name
if len(track.Artists) > 0 { if len(track.Artists) > 0 {
@@ -1578,7 +1472,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
tidalArtist = strings.Join(artistNames, ", ") tidalArtist = strings.Join(artistNames, ", ")
} }
// Verify title first
if !titlesMatch(req.TrackName, track.Title) { if !titlesMatch(req.TrackName, track.Title) {
GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.TrackName, track.Title) req.TrackName, track.Title)
@@ -1599,7 +1492,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg) return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
} }
// Final verification logging
tidalArtist := track.Artist.Name tidalArtist := track.Artist.Name
if len(track.Artists) > 0 { if len(track.Artists) > 0 {
var artistNames []string var artistNames []string
@@ -1633,7 +1525,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
} }
// Clean up any leftover .tmp files from previous failed downloads
tmpPath := outputPath + ".m4a.tmp" tmpPath := outputPath + ".m4a.tmp"
if _, err := os.Stat(tmpPath); err == nil { if _, err := os.Stat(tmpPath); err == nil {
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath) GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
@@ -1651,10 +1542,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
} }
// Log actual quality received
GoLog("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate) GoLog("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{}) parallelDone := make(chan struct{})
go func() { go func() {
@@ -1670,7 +1559,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
) )
}() }()
// Download audio file with item ID for progress tracking
GoLog("[Tidal] Starting download to: %s\n", outputPath) GoLog("[Tidal] Starting download to: %s\n", outputPath)
GoLog("[Tidal] Download URL type: %s\n", func() string { GoLog("[Tidal] Download URL type: %s\n", func() string {
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") { if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
@@ -1688,7 +1576,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
fmt.Println("[Tidal] Download completed successfully") fmt.Println("[Tidal] Download completed successfully")
// Wait for parallel operations to complete
<-parallelDone <-parallelDone
if req.ItemID != "" { if req.ItemID != "" {
@@ -1701,12 +1588,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
actualOutputPath = m4aPath actualOutputPath = m4aPath
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath) GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
} else if _, err := os.Stat(outputPath); err != nil { } else if _, err := os.Stat(outputPath); err != nil {
// Neither FLAC nor M4A exists
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath) return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
} }
// Embed metadata using parallel-fetched cover data
// Use release date from Tidal API if not provided in request
releaseDate := req.ReleaseDate releaseDate := req.ReleaseDate
if releaseDate == "" && track.Album.ReleaseDate != "" { if releaseDate == "" && track.Album.ReleaseDate != "" {
releaseDate = track.Album.ReleaseDate releaseDate = track.Album.ReleaseDate
@@ -1719,13 +1603,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
Album: req.AlbumName, Album: req.AlbumName,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
Date: releaseDate, Date: releaseDate,
TrackNumber: track.TrackNumber, // Use actual track number from Tidal TrackNumber: track.TrackNumber,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal DiscNumber: track.VolumeNumber,
ISRC: track.ISRC, // Use actual ISRC from Tidal 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
@@ -1734,21 +1618,17 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData)) GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} }
// Embed metadata based on file type
if strings.HasSuffix(actualOutputPath, ".flac") { if strings.HasSuffix(actualOutputPath, ".flac") {
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err) fmt.Printf("Warning: failed to embed metadata: %v\n", err)
} }
// Handle lyrics based on LyricsMode setting
// Mode: "embed" (default), "external" (.lrc file), "both"
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"
} }
// Save external .lrc file if mode is "external" or "both"
if lyricsMode == "external" || lyricsMode == "both" { if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Tidal] Saving external LRC file...\n") GoLog("[Tidal] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
@@ -1758,7 +1638,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
} }
// Embed lyrics if mode is "embed" or "both"
if lyricsMode == "embed" || lyricsMode == "both" { if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil { if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
@@ -1771,28 +1650,9 @@ 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") {
// Embed metadata to M4A file
// GoLog("[Tidal] Embedding metadata to M4A file...\n")
// Add lyrics to metadata if available
// if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
// metadata.Lyrics = parallelResult.LyricsLRC
// }
// SKIP metadata embedding for M4A to prevent issues with FFmpeg conversion
// M4A files from DASH are often fragmented and editing metadata might corrupt the container
// structure that FFmpeg expects. Metadata will be re-embedded after conversion to FLAC in Flutter.
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
// if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil {
// GoLog("[Tidal] Warning: failed to embed M4A metadata: %v\n", err)
// } else {
// fmt.Println("[Tidal] M4A metadata embedded successfully")
// }
} }
// Add to ISRC index for fast duplicate checking
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath) AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
return TidalDownloadResult{ return TidalDownloadResult{
-2
View File
@@ -12,11 +12,9 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Initialize services - CoverCacheManager MUST complete before app starts
await CoverCacheManager.initialize(); await CoverCacheManager.initialize();
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}'); debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
// These can run in parallel
await Future.wait([ await Future.wait([
NotificationService().initialize(), NotificationService().initialize(),
ShareIntentService().initialize(), ShareIntentService().initialize(),
+6 -9
View File
@@ -3,23 +3,21 @@ import 'package:spotiflac_android/models/track.dart';
part 'download_item.g.dart'; part 'download_item.g.dart';
/// Download status enum
enum DownloadStatus { enum DownloadStatus {
queued, queued,
downloading, downloading,
finalizing, // Embedding metadata, cover, lyrics finalizing,
completed, completed,
failed, failed,
skipped, skipped,
} }
/// Error type enum for better error handling
enum DownloadErrorType { enum DownloadErrorType {
unknown, unknown,
notFound, // Track not found on any service notFound,
rateLimit, // Rate limited by service rateLimit,
network, // Network/connection error network,
permission, // File/folder permission error permission,
} }
@JsonSerializable() @JsonSerializable()
@@ -29,7 +27,7 @@ class DownloadItem {
final String service; final String service;
final DownloadStatus status; final DownloadStatus status;
final double progress; final double progress;
final double speedMBps; // Download speed in MB/s final double speedMBps;
final String? filePath; final String? filePath;
final String? error; final String? error;
final DownloadErrorType? errorType; final DownloadErrorType? errorType;
@@ -78,7 +76,6 @@ class DownloadItem {
); );
} }
/// Get user-friendly error message based on error type
String get errorMessage { String get errorMessage {
if (error == null) return ''; if (error == null) return '';
+43 -43
View File
@@ -12,27 +12,27 @@ class AppSettings {
final bool embedLyrics; final bool embedLyrics;
final bool maxQualityCover; final bool maxQualityCover;
final bool isFirstLaunch; final bool isFirstLaunch;
final int concurrentDownloads; // 1 = sequential (default), max 3 final int concurrentDownloads;
final bool checkForUpdates; // Check for updates on app start final bool checkForUpdates;
final String updateChannel; // stable, preview final String updateChannel;
final bool hasSearchedBefore; // Hide helper text after first search final bool hasSearchedBefore;
final String folderOrganization; // none, artist, album, artist_album final String folderOrganization;
final String historyViewMode; // list, grid final String historyViewMode;
final String historyFilterMode; // all, albums, singles final String historyFilterMode;
final bool askQualityBeforeDownload; // Show quality picker before each download final bool askQualityBeforeDownload;
final String spotifyClientId; // Custom Spotify client ID (empty = use default) final String spotifyClientId;
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default) final String spotifyClientSecret;
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set) final bool useCustomSpotifyCredentials;
final String metadataSource; // spotify, deezer - source for search and metadata final String metadataSource;
final bool enableLogging; // Enable detailed logging for debugging final bool enableLogging;
final bool useExtensionProviders; // Use extension providers for downloads when available final bool useExtensionProviders;
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID final String? searchProvider;
final bool separateSingles; // Separate singles/EPs into their own folder final bool separateSingles;
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album final String albumFolderStructure;
final bool showExtensionStore; // Show Extension Store tab in navigation final bool showExtensionStore;
final String locale; // App language: 'system', 'en', 'id', etc. final String locale;
final bool enableMp3Option; // Enable MP3 quality option (default off, requires FFmpeg conversion) final bool enableMp3Option;
final String lyricsMode; // embed, external, both - how to save lyrics final String lyricsMode;
const AppSettings({ const AppSettings({
this.defaultService = 'tidal', this.defaultService = 'tidal',
@@ -43,27 +43,27 @@ class AppSettings {
this.embedLyrics = true, this.embedLyrics = true,
this.maxQualityCover = true, this.maxQualityCover = true,
this.isFirstLaunch = true, this.isFirstLaunch = true,
this.concurrentDownloads = 1, // Default: sequential (off) this.concurrentDownloads = 1,
this.checkForUpdates = true, // Default: enabled this.checkForUpdates = true,
this.updateChannel = 'stable', // Default: stable releases only this.updateChannel = 'stable',
this.hasSearchedBefore = false, // Default: show helper text this.hasSearchedBefore = false,
this.folderOrganization = 'none', // Default: no folder organization this.folderOrganization = 'none',
this.historyViewMode = 'grid', // Default: grid view this.historyViewMode = 'grid',
this.historyFilterMode = 'all', // Default: show all this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true, // Default: ask quality before download this.askQualityBeforeDownload = true,
this.spotifyClientId = '', // Default: use built-in credentials this.spotifyClientId = '',
this.spotifyClientSecret = '', // Default: use built-in credentials this.spotifyClientSecret = '',
this.useCustomSpotifyCredentials = true, // Default: use custom if set this.useCustomSpotifyCredentials = true,
this.metadataSource = 'deezer', // Default: Deezer (no rate limit) this.metadataSource = 'deezer',
this.enableLogging = false, // Default: disabled for performance this.enableLogging = false,
this.useExtensionProviders = true, // Default: use extensions when available this.useExtensionProviders = true,
this.searchProvider, // Default: null (use Deezer/Spotify) this.searchProvider,
this.separateSingles = false, // Default: disabled this.separateSingles = false,
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true, // Default: show store this.showExtensionStore = true,
this.locale = 'system', // Default: follow system language this.locale = 'system',
this.enableMp3Option = false, // Default: disabled this.enableMp3Option = false,
this.lyricsMode = 'embed', // Default: embed lyrics into file this.lyricsMode = 'embed',
}); });
AppSettings copyWith({ AppSettings copyWith({
@@ -90,7 +90,7 @@ class AppSettings {
bool? enableLogging, bool? enableLogging,
bool? useExtensionProviders, bool? useExtensionProviders,
String? searchProvider, String? searchProvider,
bool clearSearchProvider = false, // Set to true to clear searchProvider to null bool clearSearchProvider = false,
bool? separateSingles, bool? separateSingles,
String? albumFolderStructure, String? albumFolderStructure,
bool? showExtensionStore, bool? showExtensionStore,
-6
View File
@@ -9,7 +9,6 @@ const String kUseAmoledKey = 'use_amoled';
/// Default Spotify green color for fallback /// Default Spotify green color for fallback
const int kDefaultSeedColor = 0xFF1DB954; const int kDefaultSeedColor = 0xFF1DB954;
/// Theme settings model for Material Expressive 3
class ThemeSettings { class ThemeSettings {
final ThemeMode themeMode; final ThemeMode themeMode;
final bool useDynamicColor; final bool useDynamicColor;
@@ -23,10 +22,8 @@ class ThemeSettings {
this.useAmoled = false, this.useAmoled = false,
}); });
/// Get seed color as Color object
Color get seedColor => Color(seedColorValue); Color get seedColor => Color(seedColorValue);
/// Create a copy with updated values
ThemeSettings copyWith({ ThemeSettings copyWith({
ThemeMode? themeMode, ThemeMode? themeMode,
bool? useDynamicColor, bool? useDynamicColor,
@@ -41,7 +38,6 @@ class ThemeSettings {
); );
} }
/// Convert to JSON map for persistence
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
kThemeModeKey: themeMode.name, kThemeModeKey: themeMode.name,
kUseDynamicColorKey: useDynamicColor, kUseDynamicColorKey: useDynamicColor,
@@ -49,7 +45,6 @@ class ThemeSettings {
kUseAmoledKey: useAmoled, kUseAmoledKey: useAmoled,
}; };
/// Create from JSON map
factory ThemeSettings.fromJson(Map<String, dynamic> json) { factory ThemeSettings.fromJson(Map<String, dynamic> json) {
return ThemeSettings( return ThemeSettings(
themeMode: _themeModeFromString(json[kThemeModeKey] as String?), themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
@@ -74,7 +69,6 @@ class ThemeSettings {
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode; themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
} }
/// Helper to convert string to ThemeMode
ThemeMode _themeModeFromString(String? value) { ThemeMode _themeModeFromString(String? value) {
if (value == null) return ThemeMode.system; if (value == null) return ThemeMode.system;
return ThemeMode.values.firstWhere( return ThemeMode.values.firstWhere(
+3 -10
View File
@@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
part 'track.g.dart'; part 'track.g.dart';
/// Track model representing a music track
@JsonSerializable() @JsonSerializable()
class Track { class Track {
final String id; final String id;
@@ -18,9 +17,9 @@ class Track {
final String? releaseDate; final String? releaseDate;
final String? deezerId; final String? deezerId;
final ServiceAvailability? availability; final ServiceAvailability? availability;
final String? source; // Extension ID that provided this track (null for built-in sources) final String? source;
final String? albumType; // album, single, ep, compilation (from metadata API) final String? albumType;
final String? itemType; // track, album, playlist - for extension search results final String? itemType;
const Track({ const Track({
required this.id, required this.id,
@@ -41,25 +40,19 @@ class Track {
this.itemType, this.itemType,
}); });
/// Check if this track is a single (based on album_type metadata)
bool get isSingle => albumType == 'single' || albumType == 'ep'; bool get isSingle => albumType == 'single' || albumType == 'ep';
/// Check if this is an album item (not a track)
bool get isAlbumItem => itemType == 'album'; bool get isAlbumItem => itemType == 'album';
/// Check if this is a playlist item (not a track)
bool get isPlaylistItem => itemType == 'playlist'; bool get isPlaylistItem => itemType == 'playlist';
/// Check if this is an artist item (not a track)
bool get isArtistItem => itemType == 'artist'; bool get isArtistItem => itemType == 'artist';
/// Check if this is a collection (album, playlist, or artist)
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem; bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json); factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this); Map<String, dynamic> toJson() => _$TrackToJson(this);
/// Check if this track is from an extension
bool get isFromExtension => source != null && source!.isNotEmpty; bool get isFromExtension => source != null && source!.isNotEmpty;
} }
+28 -71
View File
@@ -125,7 +125,7 @@ class DownloadHistoryItem {
class DownloadHistoryState { class DownloadHistoryState {
final List<DownloadHistoryItem> items; final List<DownloadHistoryItem> items;
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup final Set<String> _downloadedSpotifyIds;
DownloadHistoryState({this.items = const []}) DownloadHistoryState({this.items = const []})
: _downloadedSpotifyIds = items : _downloadedSpotifyIds = items
@@ -133,7 +133,6 @@ class DownloadHistoryState {
.map((item) => item.spotifyId!) .map((item) => item.spotifyId!)
.toSet(); .toSet();
/// Check if a track has been downloaded (by Spotify ID)
bool isDownloaded(String spotifyId) => bool isDownloaded(String spotifyId) =>
_downloadedSpotifyIds.contains(spotifyId); _downloadedSpotifyIds.contains(spotifyId);
@@ -188,7 +187,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
} }
} }
/// Deduplicate history items by spotifyId, deezerId, or ISRC
/// Keeps the most recent entry (first occurrence since list is sorted by date desc) /// Keeps the most recent entry (first occurrence since list is sorted by date desc)
List<DownloadHistoryItem> _deduplicateHistory(List<DownloadHistoryItem> items) { List<DownloadHistoryItem> _deduplicateHistory(List<DownloadHistoryItem> items) {
final seen = <String, int>{}; // key -> index of first occurrence final seen = <String, int>{}; // key -> index of first occurrence
@@ -234,7 +232,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
} }
} }
/// Force reload from storage (useful after app restart)
Future<void> reloadFromStorage() async { Future<void> reloadFromStorage() async {
await _loadFromStorage(); await _loadFromStorage();
} }
@@ -285,7 +282,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
_saveToStorage(); _saveToStorage();
} }
/// Remove item from history by Spotify ID
void removeBySpotifyId(String spotifyId) { void removeBySpotifyId(String spotifyId) {
state = state.copyWith( state = state.copyWith(
items: state.items.where((item) => item.spotifyId != spotifyId).toList(), items: state.items.where((item) => item.spotifyId != spotifyId).toList(),
@@ -294,7 +290,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
_historyLog.d('Removed item with spotifyId: $spotifyId'); _historyLog.d('Removed item with spotifyId: $spotifyId');
} }
/// Get history item by Spotify ID
DownloadHistoryItem? getBySpotifyId(String spotifyId) { DownloadHistoryItem? getBySpotifyId(String spotifyId) {
return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull; return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull;
} }
@@ -314,12 +309,12 @@ class DownloadQueueState {
final List<DownloadItem> items; final List<DownloadItem> items;
final DownloadItem? currentDownload; final DownloadItem? currentDownload;
final bool isProcessing; final bool isProcessing;
final bool isPaused; // NEW: pause state final bool isPaused;
final String outputDir; final String outputDir;
final String filenameFormat; final String filenameFormat;
final String audioQuality; // LOSSLESS, HI_RES, HI_RES_LOSSLESS final String audioQuality;
final bool autoFallback; final bool autoFallback;
final int concurrentDownloads; // 1 = sequential, max 3 final int concurrentDownloads;
const DownloadQueueState({ const DownloadQueueState({
this.items = const [], this.items = const [],
@@ -386,14 +381,13 @@ class _ProgressUpdate {
class DownloadQueueNotifier extends Notifier<DownloadQueueState> { class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Timer? _progressTimer; Timer? _progressTimer;
int _downloadCount = 0; // Counter for connection cleanup int _downloadCount = 0;
static const _cleanupInterval = 50; // Cleanup every 50 downloads static const _cleanupInterval = 50;
static const _queueStorageKey = static const _queueStorageKey = 'download_queue';
'download_queue'; // Storage key for queue persistence
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
int _totalQueuedAtStart = 0; // Track total items when queue started int _totalQueuedAtStart = 0;
int _completedInSession = 0; // Track completed downloads in current session int _completedInSession = 0;
int _failedInSession = 0; // Track failed downloads in current session int _failedInSession = 0;
bool _isLoaded = false; bool _isLoaded = false;
final Set<String> _ensuredDirs = {}; final Set<String> _ensuredDirs = {};
@@ -411,7 +405,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return const DownloadQueueState(); return const DownloadQueueState();
} }
/// Load persisted queue from storage (for app restart recovery)
Future<void> _loadQueueFromStorage() async { Future<void> _loadQueueFromStorage() async {
if (_isLoaded) return; if (_isLoaded) return;
_isLoaded = true; _isLoaded = true;
@@ -453,7 +446,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
/// Save current queue to storage (only pending items)
Future<void> _saveQueueToStorage() async { Future<void> _saveQueueToStorage() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -479,7 +471,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
/// Start multi-progress polling for all downloads (sequential and parallel)
void _startMultiProgressPolling() { void _startMultiProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), ( _progressTimer = Timer.periodic(const Duration(milliseconds: 500), (
@@ -607,7 +598,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackName: finalizingTrackName, trackName: finalizingTrackName,
artistName: finalizingArtistName ?? '', artistName: finalizingArtistName ?? '',
); );
return; // Don't show download progress notification return;
} }
if (items.isNotEmpty) { if (items.isNotEmpty) {
@@ -651,14 +642,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
progress: notifProgress, progress: notifProgress,
total: notifTotal > 0 ? notifTotal : 1, total: notifTotal > 0 ? notifTotal : 1,
queueCount: state.queuedCount, queueCount: state.queuedCount,
).catchError((_) {}); // Ignore errors ).catchError((_) {});
} }
} }
} }
} catch (e) { } catch (_) {}
// Silently ignore polling errors to avoid spamming logs
// Polling is not critical and will retry on next interval
}
}); });
} }
@@ -725,7 +713,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(outputDir: dir); state = state.copyWith(outputDir: dir);
} }
/// Build output directory based on folder organization setting and separateSingles
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async { Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async {
String baseDir = state.outputDir; String baseDir = state.outputDir;
final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName; final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName;
@@ -794,7 +781,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return baseDir; return baseDir;
} }
/// Sanitize folder names (remove invalid characters)
String _sanitizeFolderName(String name) { String _sanitizeFolderName(String name) {
return name return name
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
@@ -866,7 +852,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}).toList(); }).toList();
state = state.copyWith(items: [...state.items, ...newItems]); state = state.copyWith(items: [...state.items, ...newItems]);
_saveQueueToStorage(); // Persist queue _saveQueueToStorage();
if (!state.isProcessing) { if (!state.isProcessing) {
Future.microtask(() => _processQueue()); Future.microtask(() => _processQueue());
@@ -951,15 +937,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.toList(); .toList();
state = state.copyWith(items: items); state = state.copyWith(items: items);
_saveQueueToStorage(); // Persist queue _saveQueueToStorage();
} }
void clearAll() { void clearAll() {
state = state.copyWith(items: [], isPaused: false); state = state.copyWith(items: [], isPaused: false);
_saveQueueToStorage(); // Clear persisted queue _saveQueueToStorage();
} }
/// Pause the download queue
void pauseQueue() { void pauseQueue() {
if (state.isProcessing && !state.isPaused) { if (state.isProcessing && !state.isPaused) {
state = state.copyWith(isPaused: true); state = state.copyWith(isPaused: true);
@@ -968,7 +953,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
/// Resume the download queue
void resumeQueue() { void resumeQueue() {
if (state.isPaused) { if (state.isPaused) {
state = state.copyWith(isPaused: false); state = state.copyWith(isPaused: false);
@@ -979,7 +963,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
/// Toggle pause/resume
void togglePause() { void togglePause() {
if (state.isPaused) { if (state.isPaused) {
resumeQueue(); resumeQueue();
@@ -988,7 +971,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
/// Retry a failed or skipped download
void retryItem(String id) { void retryItem(String id) {
final item = state.items.where((i) => i.id == id).firstOrNull; final item = state.items.where((i) => i.id == id).firstOrNull;
if (item == null) { if (item == null) {
@@ -1025,14 +1007,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
/// Remove a specific item from queue
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(); // Persist queue _saveQueueToStorage();
} }
/// Run post-processing hooks on a downloaded file
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);
@@ -1079,7 +1059,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
/// Upgrade Spotify cover URL to max quality (~2000x2000)
/// Same logic as Go backend cover.go /// Same logic as Go backend cover.go
String _upgradeToMaxQualityCover(String coverUrl) { String _upgradeToMaxQualityCover(String coverUrl) {
const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small) const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
@@ -1098,7 +1077,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return result; return result;
} }
/// Embed metadata and cover to a FLAC file after M4A conversion
Future<void> _embedMetadataAndCover( Future<void> _embedMetadataAndCover(
String flacPath, String flacPath,
Track track, { Track track, {
@@ -1155,12 +1133,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (track.trackNumber != null) { if (track.trackNumber != null) {
metadata['TRACKNUMBER'] = track.trackNumber.toString(); metadata['TRACKNUMBER'] = track.trackNumber.toString();
metadata['TRACK'] = track.trackNumber.toString(); // Compatibility metadata['TRACK'] = track.trackNumber.toString();
} }
if (track.discNumber != null) { if (track.discNumber != null) {
metadata['DISCNUMBER'] = track.discNumber.toString(); metadata['DISCNUMBER'] = track.discNumber.toString();
metadata['DISC'] = track.discNumber.toString(); // Compatibility metadata['DISC'] = track.discNumber.toString();
} }
if (track.releaseDate != null) { if (track.releaseDate != null) {
@@ -1172,7 +1150,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
metadata['ISRC'] = track.isrc!; metadata['ISRC'] = track.isrc!;
} }
// Extended metadata from enrichment (genre, label, copyright)
if (genre != null && genre.isNotEmpty) { if (genre != null && genre.isNotEmpty) {
metadata['GENRE'] = genre; metadata['GENRE'] = genre;
_log.d('Adding GENRE: $genre'); _log.d('Adding GENRE: $genre');
@@ -1189,20 +1166,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Metadata map content: $metadata'); _log.d('Metadata map content: $metadata');
try { try {
// Convert duration from seconds to milliseconds for better lyrics matching
final durationMs = track.duration * 1000; final durationMs = track.duration * 1000;
final lrcContent = await PlatformBridge.getLyricsLRC( final lrcContent = await PlatformBridge.getLyricsLRC(
track.id, // spotifyID track.id,
track.name, track.name,
track.artistName, track.artistName,
filePath: '', // No local file path yet (processed in memory) filePath: '',
durationMs: durationMs, durationMs: durationMs,
); );
if (lrcContent.isNotEmpty) { if (lrcContent.isNotEmpty) {
metadata['LYRICS'] = lrcContent; metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent; // Fallback for some players metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
} }
} catch (e) { } catch (e) {
@@ -1240,7 +1216,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
/// Embed metadata, lyrics, and cover to a MP3 file
Future<void> _embedMetadataToMp3(String mp3Path, Track track) async { Future<void> _embedMetadataToMp3(String mp3Path, Track track) async {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
@@ -1310,7 +1285,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('MP3 Metadata map content: $metadata'); _log.d('MP3 Metadata map content: $metadata');
// Fetch lyrics if embedLyrics is enabled
if (settings.embedLyrics) { if (settings.embedLyrics) {
try { try {
final durationMs = track.duration * 1000; final durationMs = track.duration * 1000;
@@ -1365,7 +1339,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
Future<void> _processQueue() async { Future<void> _processQueue() async {
if (state.isProcessing) return; // Prevent multiple concurrent processing if (state.isProcessing) return;
state = state.copyWith(isProcessing: true); state = state.copyWith(isProcessing: true);
_log.i('Starting queue processing...'); _log.i('Starting queue processing...');
@@ -1462,7 +1436,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
/// Sequential download processing (uses multi-progress system with single item)
Future<void> _processQueueSequential() async { Future<void> _processQueueSequential() async {
_startMultiProgressPolling(); _startMultiProgressPolling();
@@ -1508,10 +1481,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_stopProgressPolling(); _stopProgressPolling();
} }
/// Parallel download processing with worker pool
Future<void> _processQueueParallel() async { Future<void> _processQueueParallel() async {
final maxConcurrent = state.concurrentDownloads; final maxConcurrent = state.concurrentDownloads;
final activeDownloads = <String, Future<void>>{}; // Map item ID to future final activeDownloads = <String, Future<void>>{};
_startMultiProgressPolling(); _startMultiProgressPolling();
@@ -1565,7 +1538,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_stopProgressPolling(); _stopProgressPolling();
} }
/// Download a single item (used by both sequential and parallel processing)
Future<void> _downloadSingleItem(DownloadItem item) async { Future<void> _downloadSingleItem(DownloadItem item) async {
_log.d('Processing: ${item.track.name} by ${item.track.artistName}'); _log.d('Processing: ${item.track.name} by ${item.track.artistName}');
_log.d('Cover URL: ${item.track.coverUrl}'); _log.d('Cover URL: ${item.track.coverUrl}');
@@ -1628,7 +1600,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackToDownload.albumName, trackToDownload.albumName,
albumArtist: data['album_artist'] as String?, albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?, coverUrl: data['images'] as String?,
// duration_ms from Go is in milliseconds, Track.duration is in seconds
duration: duration:
((data['duration_ms'] as int?) ?? ((data['duration_ms'] as int?) ??
(trackToDownload.duration * 1000)) ~/ (trackToDownload.duration * 1000)) ~/
@@ -1675,7 +1646,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? genre; String? genre;
String? label; String? label;
// Try to get Deezer track ID from various sources
String? deezerTrackId = trackToDownload.deezerId; String? deezerTrackId = trackToDownload.deezerId;
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
deezerTrackId = trackToDownload.id.split(':')[1]; deezerTrackId = trackToDownload.id.split(':')[1];
@@ -1696,7 +1666,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} catch (e) { } catch (e) {
_log.w('Failed to fetch extended metadata from Deezer: $e'); _log.w('Failed to fetch extended metadata from Deezer: $e');
// Continue without extended metadata
} }
} }
@@ -1728,7 +1697,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
releaseDate: trackToDownload.releaseDate, releaseDate: trackToDownload.releaseDate,
itemId: item.id, itemId: item.id,
durationMs: trackToDownload.duration, durationMs: trackToDownload.duration,
source: trackToDownload.source, // Pass extension ID that provided this track source: trackToDownload.source,
genre: genre, genre: genre,
label: label, label: label,
lyricsMode: settings.lyricsMode, lyricsMode: settings.lyricsMode,
@@ -1754,9 +1723,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
discNumber: trackToDownload.discNumber ?? 1, discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate, releaseDate: trackToDownload.releaseDate,
preferredService: item.service, preferredService: item.service,
itemId: item.id, // Pass item ID for progress tracking itemId: item.id,
durationMs: durationMs: trackToDownload.duration,
trackToDownload.duration, // Duration in ms for verification
genre: genre, genre: genre,
label: label, label: label,
lyricsMode: settings.lyricsMode, lyricsMode: settings.lyricsMode,
@@ -1809,8 +1777,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (result['success'] == true) { if (result['success'] == true) {
var filePath = result['file_path'] as String?; var filePath = result['file_path'] as String?;
// Track if this was an existing file (not a new download)
// This is important to prevent converting existing FLAC files to MP3
final wasExisting = filePath != null && filePath.startsWith('EXISTS:'); final wasExisting = filePath != null && filePath.startsWith('EXISTS:');
if (wasExisting) { if (wasExisting) {
filePath = filePath.substring(7); // Remove "EXISTS:" prefix filePath = filePath.substring(7); // Remove "EXISTS:" prefix
@@ -1912,7 +1878,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
); );
} }
// Get extended metadata from backend response
final backendGenre = result['genre'] as String?; final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?; final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?; final backendCopyright = result['copyright'] as String?;
@@ -1962,15 +1927,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return; return;
} }
// Convert FLAC to MP3 if MP3 quality was selected
// IMPORTANT: Only convert NEW downloads, never convert existing files
// to prevent overwriting the user's existing FLAC files
if (quality == 'MP3' && filePath != null && filePath.endsWith('.flac')) { if (quality == 'MP3' && filePath != null && filePath.endsWith('.flac')) {
if (wasExisting) { if (wasExisting) {
// User wanted MP3 but an existing FLAC file was found
// Do NOT convert it - that would delete their existing FLAC
_log.i('MP3 requested but existing FLAC found - skipping conversion to preserve original file'); _log.i('MP3 requested but existing FLAC found - skipping conversion to preserve original file');
// Keep the existing FLAC file as-is
} else { } else {
_log.i('MP3 quality selected, converting FLAC to MP3...'); _log.i('MP3 quality selected, converting FLAC to MP3...');
updateItemStatus( updateItemStatus(
@@ -1991,7 +1950,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
actualQuality = 'MP3 320kbps'; actualQuality = 'MP3 320kbps';
_log.i('Successfully converted to MP3: $mp3Path'); _log.i('Successfully converted to MP3: $mp3Path');
// Embed metadata, lyrics, and cover to the MP3 file
_log.i('Embedding metadata to MP3...'); _log.i('Embedding metadata to MP3...');
updateItemStatus( updateItemStatus(
item.id, item.id,
@@ -2050,7 +2008,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? normalizedAlbumArtist ? normalizedAlbumArtist
: null; : null;
// For MP3 files, don't save FLAC bitDepth/sampleRate - they're not applicable
final isMp3 = filePath.endsWith('.mp3'); final isMp3 = filePath.endsWith('.mp3');
final historyBitDepth = isMp3 ? null : backendBitDepth; final historyBitDepth = isMp3 ? null : backendBitDepth;
final historySampleRate = isMp3 ? null : backendSampleRate; final historySampleRate = isMp3 ? null : backendSampleRate;
+15 -40
View File
@@ -5,7 +5,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
final _log = AppLogger('ExtensionProvider'); final _log = AppLogger('ExtensionProvider');
/// Represents an installed extension
class Extension { class Extension {
final String id; final String id;
final String name; final String name;
@@ -14,19 +13,19 @@ class Extension {
final String author; final String author;
final String description; final String description;
final bool enabled; final bool enabled;
final String status; // 'loaded', 'error', 'disabled' final String status;
final String? errorMessage; final String? errorMessage;
final String? iconPath; // Path to extension icon final String? iconPath;
final List<String> permissions; final List<String> permissions;
final List<ExtensionSetting> settings; final List<ExtensionSetting> settings;
final List<QualityOption> qualityOptions; // Custom quality options for download providers final List<QualityOption> qualityOptions;
final bool hasMetadataProvider; final bool hasMetadataProvider;
final bool hasDownloadProvider; final bool hasDownloadProvider;
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
final SearchBehavior? searchBehavior; // Custom search behavior final SearchBehavior? searchBehavior;
final URLHandler? urlHandler; // Custom URL handling final URLHandler? urlHandler;
final TrackMatching? trackMatching; // Custom track matching final TrackMatching? trackMatching;
final PostProcessing? postProcessing; // Post-processing hooks final PostProcessing? postProcessing;
const Extension({ const Extension({
required this.id, required this.id,
@@ -140,7 +139,6 @@ class Extension {
bool get hasPostProcessing => postProcessing?.enabled ?? false; bool get hasPostProcessing => postProcessing?.enabled ?? false;
} }
/// Custom search behavior configuration
class SearchBehavior { class SearchBehavior {
final bool enabled; final bool enabled;
final String? placeholder; final String? placeholder;
@@ -172,8 +170,6 @@ class SearchBehavior {
); );
} }
/// Get thumbnail size based on configuration
/// Returns (width, height) tuple
(double, double) getThumbnailSize({double defaultSize = 56}) { (double, double) getThumbnailSize({double defaultSize = 56}) {
if (thumbnailWidth != null && thumbnailHeight != null) { if (thumbnailWidth != null && thumbnailHeight != null) {
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble()); return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
@@ -191,11 +187,10 @@ class SearchBehavior {
} }
} }
/// Custom track matching configuration
class TrackMatching { class TrackMatching {
final bool customMatching; final bool customMatching;
final String? strategy; // "isrc", "name", "duration", "custom" final String? strategy;
final int durationTolerance; // in seconds final int durationTolerance;
const TrackMatching({ const TrackMatching({
required this.customMatching, required this.customMatching,
@@ -212,7 +207,6 @@ class TrackMatching {
} }
} }
/// Post-processing configuration
class PostProcessing { class PostProcessing {
final bool enabled; final bool enabled;
final List<PostProcessingHook> hooks; final List<PostProcessingHook> hooks;
@@ -262,7 +256,6 @@ class URLHandler {
} }
} }
/// A post-processing hook
class PostProcessingHook { class PostProcessingHook {
final String id; final String id;
final String name; final String name;
@@ -289,12 +282,11 @@ class PostProcessingHook {
} }
} }
/// Represents a quality option for download providers
class QualityOption { class QualityOption {
final String id; final String id;
final String label; final String label;
final String? description; final String? description;
final List<QualitySpecificSetting> settings; // Quality-specific settings final List<QualitySpecificSetting> settings;
const QualityOption({ const QualityOption({
required this.id, required this.id,
@@ -315,14 +307,13 @@ class QualityOption {
} }
} }
/// Represents a setting that's specific to a quality option
class QualitySpecificSetting { class QualitySpecificSetting {
final String key; final String key;
final String label; final String label;
final String type; // 'string', 'number', 'boolean', 'select' final String type;
final dynamic defaultValue; final dynamic defaultValue;
final String? description; final String? description;
final List<String>? options; // For select type final List<String>? options;
final bool required; final bool required;
final bool secret; final bool secret;
@@ -351,16 +342,15 @@ class QualitySpecificSetting {
} }
} }
/// Represents a setting field for an extension
class ExtensionSetting { class ExtensionSetting {
final String key; final String key;
final String label; final String label;
final String type; // 'string', 'number', 'boolean', 'select', 'button' final String type;
final dynamic defaultValue; final dynamic defaultValue;
final String? description; final String? description;
final List<String>? options; // For select type final List<String>? options;
final bool required; final bool required;
final String? action; // For button type: JS function name to call final String? action;
const ExtensionSetting({ const ExtensionSetting({
required this.key, required this.key,
@@ -387,7 +377,6 @@ class ExtensionSetting {
} }
} }
/// State for extension management
class ExtensionState { class ExtensionState {
final List<Extension> extensions; final List<Extension> extensions;
final List<String> providerPriority; final List<String> providerPriority;
@@ -425,7 +414,6 @@ class ExtensionState {
} }
/// Provider for managing extensions
class ExtensionNotifier extends Notifier<ExtensionState> { class ExtensionNotifier extends Notifier<ExtensionState> {
@override @override
ExtensionState build() { ExtensionState build() {
@@ -451,7 +439,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Load all extensions from directory
Future<void> loadExtensions(String dirPath) async { Future<void> loadExtensions(String dirPath) async {
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
@@ -486,12 +473,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Clear any error state
void clearError() { void clearError() {
state = state.copyWith(error: null); state = state.copyWith(error: null);
} }
/// Install extension from file (auto-upgrades if already installed with newer version)
Future<bool> installExtension(String filePath) async { Future<bool> installExtension(String filePath) async {
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
@@ -508,8 +493,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Check if a package file is an upgrade for an existing extension
/// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed}
Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async { Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
try { try {
return await PlatformBridge.checkExtensionUpgrade(filePath); return await PlatformBridge.checkExtensionUpgrade(filePath);
@@ -519,7 +502,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Upgrade an existing extension from a new package file
Future<bool> upgradeExtension(String filePath) async { Future<bool> upgradeExtension(String filePath) async {
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
@@ -553,7 +535,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Enable or disable an extension
Future<void> setExtensionEnabled(String extensionId, bool enabled) async { Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
try { try {
await PlatformBridge.setExtensionEnabled(extensionId, enabled); await PlatformBridge.setExtensionEnabled(extensionId, enabled);
@@ -600,7 +581,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Update settings for an extension
Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async { Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
try { try {
await PlatformBridge.setExtensionSettings(extensionId, settings); await PlatformBridge.setExtensionSettings(extensionId, settings);
@@ -621,7 +601,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Set provider priority order
Future<void> setProviderPriority(List<String> priority) async { Future<void> setProviderPriority(List<String> priority) async {
try { try {
await PlatformBridge.setProviderPriority(priority); await PlatformBridge.setProviderPriority(priority);
@@ -643,7 +622,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Set metadata provider priority order
Future<void> setMetadataProviderPriority(List<String> priority) async { Future<void> setMetadataProviderPriority(List<String> priority) async {
try { try {
await PlatformBridge.setMetadataProviderPriority(priority); await PlatformBridge.setMetadataProviderPriority(priority);
@@ -665,7 +643,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
} }
/// Get extension by ID
Extension? getExtension(String extensionId) { Extension? getExtension(String extensionId) {
try { try {
return state.extensions.firstWhere((ext) => ext.id == extensionId); return state.extensions.firstWhere((ext) => ext.id == extensionId);
@@ -679,7 +656,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return state.extensions.where((ext) => ext.enabled).toList(); return state.extensions.where((ext) => ext.enabled).toList();
} }
/// Get all download providers (built-in + extensions)
List<String> getAllDownloadProviders() { List<String> getAllDownloadProviders() {
final providers = ['tidal', 'qobuz', 'amazon']; final providers = ['tidal', 'qobuz', 'amazon'];
for (final ext in state.extensions) { for (final ext in state.extensions) {
@@ -700,7 +676,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
return providers; return providers;
} }
/// Get all extensions that provide custom search
List<Extension> get searchProviders { List<Extension> get searchProviders {
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList(); return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
} }
@@ -121,7 +121,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>)) .map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
} catch (e) { } catch (e) {
// Ignore parse errors
} }
} }
@@ -266,7 +265,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
} }
} }
/// Provider instance
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>( final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
RecentAccessNotifier.new, RecentAccessNotifier.new,
); );
-3
View File
@@ -30,7 +30,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
} }
/// Run one-time migrations for settings
Future<void> _runMigrations(SharedPreferences prefs) async { Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
@@ -51,7 +50,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
await prefs.setString(_settingsKey, jsonEncode(state.toJson())); await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
} }
/// Apply current Spotify credentials to Go backend
Future<void> _applySpotifyCredentials() async { Future<void> _applySpotifyCredentials() async {
if (state.spotifyClientId.isNotEmpty && if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) { state.spotifyClientSecret.isNotEmpty) {
@@ -93,7 +91,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
void setLyricsMode(String mode) { void setLyricsMode(String mode) {
// Valid modes: embed, external, both
if (mode == 'embed' || mode == 'external' || mode == 'both') { if (mode == 'embed' || mode == 'external' || mode == 'both') {
state = state.copyWith(lyricsMode: mode); state = state.copyWith(lyricsMode: mode);
_saveSettings(); _saveSettings();
-7
View File
@@ -52,7 +52,6 @@ class StoreCategory {
} }
} }
/// Represents an extension in the store
class StoreExtension { class StoreExtension {
final String id; final String id;
final String name; final String name;
@@ -118,7 +117,6 @@ class StoreExtension {
} }
} }
/// State for extension store
class StoreState { class StoreState {
final List<StoreExtension> extensions; final List<StoreExtension> extensions;
final String? selectedCategory; final String? selectedCategory;
@@ -200,7 +198,6 @@ class StoreNotifier extends Notifier<StoreState> {
return const StoreState(); return const StoreState();
} }
/// Initialize the store
Future<void> initialize(String cacheDir) async { Future<void> initialize(String cacheDir) async {
if (state.isInitialized) return; if (state.isInitialized) return;
@@ -234,7 +231,6 @@ class StoreNotifier extends Notifier<StoreState> {
} }
} }
/// Set category filter
void setCategory(String? category) { void setCategory(String? category) {
if (category == null) { if (category == null) {
state = state.copyWith(clearCategory: true); state = state.copyWith(clearCategory: true);
@@ -248,7 +244,6 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(searchQuery: query); state = state.copyWith(searchQuery: query);
} }
/// Clear search
void clearSearch() { void clearSearch() {
state = state.copyWith(searchQuery: '', clearCategory: true); state = state.copyWith(searchQuery: '', clearCategory: true);
} }
@@ -279,7 +274,6 @@ class StoreNotifier extends Notifier<StoreState> {
} }
} }
/// Update an installed extension
Future<bool> updateExtension(String extensionId, String tempDir) async { Future<bool> updateExtension(String extensionId, String tempDir) async {
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
@@ -305,7 +299,6 @@ class StoreNotifier extends Notifier<StoreState> {
} }
} }
/// Clear error
void clearError() { void clearError() {
state = state.copyWith(clearError: true); state = state.copyWith(clearError: true);
} }
-1
View File
@@ -34,7 +34,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
); );
} catch (e) { } catch (e) {
debugPrint('Error loading theme settings: $e'); debugPrint('Error loading theme settings: $e');
// Keep default state on error
} }
} }
+2 -17
View File
@@ -89,7 +89,6 @@ class TrackState {
} }
} }
/// Represents an album in artist discography
class ArtistAlbum { class ArtistAlbum {
final String id; final String id;
final String name; final String name;
@@ -112,7 +111,6 @@ class ArtistAlbum {
}); });
} }
/// Represents an artist in search results
class SearchArtist { class SearchArtist {
final String id; final String id;
final String name; final String name;
@@ -130,7 +128,6 @@ class SearchArtist {
} }
class TrackNotifier extends Notifier<TrackState> { class TrackNotifier extends Notifier<TrackState> {
/// Request ID to track and cancel outdated requests
int _currentRequestId = 0; int _currentRequestId = 0;
@override @override
@@ -213,14 +210,8 @@ class TrackNotifier extends Notifier<TrackState> {
Map<String, dynamic> metadata; Map<String, dynamic> metadata;
try { try {
// ignore: avoid_print
print('[FetchURL] Fetching $type with Deezer fallback enabled...');
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
// ignore: avoid_print
print('[FetchURL] Metadata fetch success');
} catch (e) { } catch (e) {
// ignore: avoid_print
print('[FetchURL] Metadata fetch failed: $e');
rethrow; rethrow;
} }
@@ -263,7 +254,7 @@ class TrackNotifier extends Notifier<TrackState> {
final albumsList = metadata['albums'] as List<dynamic>; final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList(); final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
state = TrackState( state = TrackState(
tracks: [], // No tracks for artist view tracks: [],
isLoading: false, isLoading: false,
artistId: artistInfo['id'] as String?, artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?, artistName: artistInfo['name'] as String?,
@@ -397,7 +388,6 @@ class TrackNotifier extends Notifier<TrackState> {
} }
} }
/// Perform custom search using a specific extension
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async { Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
@@ -429,7 +419,7 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
searchArtists: [], // Custom search doesn't return artists searchArtists: [],
isLoading: false, isLoading: false,
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
searchExtensionId: extensionId, // Store which extension was used searchExtensionId: extensionId, // Store which extension was used
@@ -477,8 +467,6 @@ class TrackNotifier extends Notifier<TrackState> {
tracks[index] = updatedTrack; tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks); state = state.copyWith(tracks: tracks);
} catch (e) { } catch (e) {
// Silently ignore availability check errors
// This is a background operation that shouldn't disrupt the user
} }
} }
@@ -494,7 +482,6 @@ class TrackNotifier extends Notifier<TrackState> {
state = state.copyWith(hasSearchText: hasText); state = state.copyWith(hasSearchText: hasText);
} }
/// Set recent access mode state
void setShowingRecentAccess(bool showing) { void setShowingRecentAccess(bool showing) {
state = state.copyWith(isShowingRecentAccess: showing); state = state.copyWith(isShowingRecentAccess: showing);
} }
@@ -584,8 +571,6 @@ class TrackNotifier extends Notifier<TrackState> {
); );
} }
/// Pre-warm track ID cache for faster downloads
/// Runs in background, doesn't block UI
void _preWarmCacheForTracks(List<Track> tracks) { void _preWarmCacheForTracks(List<Track> tracks) {
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList(); final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
if (tracksWithIsrc.isEmpty) return; if (tracksWithIsrc.isEmpty) return;
+1 -12
View File
@@ -13,7 +13,6 @@ import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
/// Simple in-memory cache for album tracks
class _AlbumCache { class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {}; static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10); static const Duration _ttl = Duration(minutes: 10);
@@ -39,7 +38,6 @@ class _CacheEntry {
_CacheEntry(this.tracks, this.expiresAt); _CacheEntry(this.tracks, this.expiresAt);
} }
/// Album detail screen with Material Expressive 3 design
class AlbumScreen extends ConsumerStatefulWidget { class AlbumScreen extends ConsumerStatefulWidget {
final String albumId; final String albumId;
final String albumName; final String albumName;
@@ -99,7 +97,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
void _onScroll() { void _onScroll() {
// Show title in AppBar when scrolled past the header (320 - kToolbarHeight + info card top)
final shouldShow = _scrollController.offset > 280; final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) { if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow); setState(() => _showTitleInAppBar = shouldShow);
@@ -121,7 +118,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}); });
} }
} catch (_) { } catch (_) {
// Ignore palette extraction errors
} }
} }
@@ -132,12 +128,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (widget.albumId.startsWith('deezer:')) { if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', ''); final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
// ignore: avoid_print
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId); metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
} else { } else {
// ignore: avoid_print
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
final url = 'https://open.spotify.com/album/${widget.albumId}'; final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
} }
@@ -219,7 +211,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
expandedHeight: 320, expandedHeight: 320,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
title: AnimatedOpacity( title: AnimatedOpacity(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@@ -261,7 +253,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
), ),
), ),
), ),
// Cover image centered - fade out when collapsing
AnimatedOpacity( AnimatedOpacity(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
@@ -449,7 +440,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
} }
/// Build error widget with special handling for rate limit (429)
Widget _buildErrorWidget(String error, ColorScheme colorScheme) { Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') || final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') || error.toLowerCase().contains('rate limit') ||
@@ -512,7 +502,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
} }
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
class _AlbumTrackItem extends ConsumerWidget { class _AlbumTrackItem extends ConsumerWidget {
final Track track; final Track track;
final VoidCallback onDownload; final VoidCallback onDownload;
-4
View File
@@ -97,7 +97,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
int? _monthlyListeners; int? _monthlyListeners;
String? _error; String? _error;
// Sticky title state
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@@ -310,7 +309,6 @@ return Scaffold(
); );
} }
/// Build Spotify-style header with full-width image and artist name overlay
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) { Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
String? imageUrl = _headerImageUrl; String? imageUrl = _headerImageUrl;
if (imageUrl == null || imageUrl.isEmpty) { if (imageUrl == null || imageUrl.isEmpty) {
@@ -479,7 +477,6 @@ if (hasValidImage)
); );
} }
/// Build a single popular track item with dynamic download status
Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) { Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) {
final queueItem = ref.watch( final queueItem = ref.watch(
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
@@ -608,7 +605,6 @@ if (hasValidImage)
_downloadTrack(track); _downloadTrack(track);
} }
/// Build download button with status indicator for popular tracks
Widget _buildPopularDownloadButton({ Widget _buildPopularDownloadButton({
required Track track, required Track track,
required ColorScheme colorScheme, required ColorScheme colorScheme,
-4
View File
@@ -77,7 +77,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}); });
} }
} catch (_) { } catch (_) {
// Ignore palette extraction errors
} }
} }
@@ -508,9 +507,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) { Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
final discMap = _groupTracksByDisc(tracks); final discMap = _groupTracksByDisc(tracks);
// Single disc - use simple list
if (discMap.length <= 1) { if (discMap.length <= 1) {
// Single disc - use simple list
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
@@ -525,7 +522,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
); );
} }
// Multiple discs - build list with separators
final discNumbers = discMap.keys.toList()..sort(); final discNumbers = discMap.keys.toList()..sort();
final List<Widget> children = []; final List<Widget> children = [];
+2 -42
View File
@@ -75,7 +75,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
} }
} }
/// Called when trackState changes - used to sync search bar with state
void _onTrackStateChanged(TrackState? previous, TrackState next) { void _onTrackStateChanged(TrackState? previous, TrackState next) {
if (previous != null && if (previous != null &&
!next.hasContent && !next.hasContent &&
@@ -96,7 +95,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (searchProvider == null || searchProvider.isEmpty) return false; if (searchProvider == null || searchProvider.isEmpty) return false;
// Check if the extension is enabled and has search capability
final extension = extState.extensions.where((e) => e.id == searchProvider && e.enabled).firstOrNull; final extension = extState.extensions.where((e) => e.id == searchProvider && e.enabled).firstOrNull;
return extension != null; return extension != null;
} }
@@ -130,10 +128,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
} }
} }
/// Execute live search with concurrency protection
/// Prevents race conditions in extensions by ensuring only one search runs at a time
Future<void> _executeLiveSearch(String query) async { Future<void> _executeLiveSearch(String query) async {
// If a search is already in progress, queue this one
if (_isLiveSearchInProgress) { if (_isLiveSearchInProgress) {
_pendingLiveSearchQuery = query; _pendingLiveSearchQuery = query;
return; return;
@@ -151,13 +146,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final pending = _pendingLiveSearchQuery; final pending = _pendingLiveSearchQuery;
_pendingLiveSearchQuery = null; _pendingLiveSearchQuery = null;
// Execute pending query if it's different from what we just searched
// and still matches current text field content
if (pending != null && if (pending != null &&
pending != query && pending != query &&
mounted && mounted &&
_urlController.text.trim() == pending) { _urlController.text.trim() == pending) {
// Small delay to let extension's state settle
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
if (mounted && _urlController.text.trim() == pending) { if (mounted && _urlController.text.trim() == pending) {
_executeLiveSearch(pending); _executeLiveSearch(pending);
@@ -224,7 +216,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
ref.read(settingsProvider.notifier).setHasSearchedBefore(); ref.read(settingsProvider.notifier).setHasSearchedBefore();
} }
/// Navigate to detail screen based on fetched content type
void _navigateToDetailIfNeeded() { void _navigateToDetailIfNeeded() {
final trackState = ref.read(trackProvider); final trackState = ref.read(trackProvider);
@@ -356,7 +347,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
final l10n = context.l10n; final l10n = context.l10n;
// Show quality picker if enabled in settings
if (settings.askQualityBeforeDownload) { if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show( DownloadServicePicker.show(
this.context, this.context,
@@ -676,7 +666,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
); );
} }
/// Build recent access history section (shown when search focused)
Widget _buildRecentAccess(List<RecentAccessItem> items, ColorScheme colorScheme) { Widget _buildRecentAccess(List<RecentAccessItem> items, ColorScheme colorScheme) {
final historyItems = ref.read(downloadHistoryProvider).items; final historyItems = ref.read(downloadHistoryProvider).items;
@@ -690,9 +679,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
albumGroups.putIfAbsent(albumKey, () => []).add(h); albumGroups.putIfAbsent(albumKey, () => []).add(h);
} }
// Convert to RecentAccessItem based on track count:
// - 1 track: show as individual Track
// - 2+ tracks: show as Album
final downloadItems = <RecentAccessItem>[]; final downloadItems = <RecentAccessItem>[];
for (final entry in albumGroups.entries) { for (final entry in albumGroups.entries) {
final tracks = entry.value; final tracks = entry.value;
@@ -703,7 +689,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
: mostRecent.artistName; : mostRecent.artistName;
if (tracks.length == 1) { if (tracks.length == 1) {
// Single track - show as Track
downloadItems.add(RecentAccessItem( downloadItems.add(RecentAccessItem(
id: mostRecent.spotifyId ?? mostRecent.id, id: mostRecent.spotifyId ?? mostRecent.id,
name: mostRecent.trackName, name: mostRecent.trackName,
@@ -714,7 +699,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
providerId: 'download', providerId: 'download',
)); ));
} else { } else {
// Multiple tracks - show as Album
downloadItems.add(RecentAccessItem( downloadItems.add(RecentAccessItem(
id: '${mostRecent.albumName}|$artistForKey', id: '${mostRecent.albumName}|$artistForKey',
name: mostRecent.albumName, name: mostRecent.albumName,
@@ -727,10 +711,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
} }
} }
// Sort by most recent and take top 10
downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
// Filter out hidden downloads (use ref.watch for reactivity)
final hiddenIds = ref.watch(recentAccessProvider.select((s) => s.hiddenDownloadIds)); final hiddenIds = ref.watch(recentAccessProvider.select((s) => s.hiddenDownloadIds));
final visibleDownloads = downloadItems final visibleDownloads = downloadItems
.where((item) => !hiddenIds.contains(item.id)) .where((item) => !hiddenIds.contains(item.id))
@@ -768,11 +750,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (uniqueItems.isNotEmpty) if (uniqueItems.isNotEmpty)
TextButton( TextButton(
onPressed: () { onPressed: () {
// Hide ALL download items (not just visible ones)
for (final item in downloadItems) { for (final item in downloadItems) {
ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id); ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id);
} }
// Clear non-download recent history
ref.read(recentAccessProvider.notifier).clearHistory(); ref.read(recentAccessProvider.notifier).clearHistory();
}, },
child: Text( child: Text(
@@ -784,7 +764,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (uniqueItems.isEmpty && hasHiddenDownloads) if (uniqueItems.isEmpty && hasHiddenDownloads)
// Show "Show All" button when recents is empty but there are hidden downloads
Center( Center(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24), padding: const EdgeInsets.symmetric(vertical: 24),
@@ -897,10 +876,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant), icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant),
onPressed: () { onPressed: () {
if (item.providerId == 'download') { if (item.providerId == 'download') {
// For download items, hide from recents without deleting the file
ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id); ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id);
} else { } else {
// For other items, remove from recent history
ref.read(recentAccessProvider.notifier).removeItem(item); ref.read(recentAccessProvider.notifier).removeItem(item);
} }
}, },
@@ -936,7 +913,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
)); ));
} }
case RecentAccessType.album: case RecentAccessType.album:
// Handle downloaded albums - navigate to DownloadedAlbumScreen
if (item.providerId == 'download') { if (item.providerId == 'download') {
Navigator.push(context, MaterialPageRoute( Navigator.push(context, MaterialPageRoute(
builder: (context) => DownloadedAlbumScreen( builder: (context) => DownloadedAlbumScreen(
@@ -1000,7 +976,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
); );
} }
/// Build error widget with special handling for rate limit (429)
Widget _buildErrorWidget(String error, ColorScheme colorScheme) { Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') || final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') || error.toLowerCase().contains('rate limit') ||
@@ -1427,7 +1402,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
)); ));
} }
/// Get search hint based on selected provider
String _getSearchHint() { String _getSearchHint() {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final searchProvider = settings.searchProvider; final searchProvider = settings.searchProvider;
@@ -1474,11 +1448,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
), ),
prefixIcon: _SearchProviderDropdown( prefixIcon: _SearchProviderDropdown(
onProviderChanged: () { onProviderChanged: () {
// Reset search state when provider changes
_lastSearchQuery = null; _lastSearchQuery = null;
// Force rebuild to update hint text
setState(() {}); setState(() {});
// Re-trigger search if there's text
final text = _urlController.text.trim(); final text = _urlController.text.trim();
if (text.isNotEmpty && text.length >= _minLiveSearchChars) { if (text.isNotEmpty && text.length >= _minLiveSearchChars) {
_performSearch(text); _performSearch(text);
@@ -1514,9 +1485,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
); );
} }
/// Handle Enter key press - search or fetch URL
void _onSearchSubmitted() { void _onSearchSubmitted() {
// Cancel any pending live search since user explicitly pressed enter
_liveSearchDebounce?.cancel(); _liveSearchDebounce?.cancel();
_pendingLiveSearchQuery = null; _pendingLiveSearchQuery = null;
@@ -1549,13 +1518,11 @@ class _SearchProviderDropdown extends ConsumerWidget {
final extState = ref.watch(extensionProvider); final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
// Get current provider info
final currentProvider = settings.searchProvider; final currentProvider = settings.searchProvider;
final searchProviders = extState.extensions final searchProviders = extState.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch) .where((ext) => ext.enabled && ext.hasCustomSearch)
.toList(); .toList();
// Find current provider extension
Extension? currentExt; Extension? currentExt;
if (currentProvider != null && currentProvider.isNotEmpty) { if (currentProvider != null && currentProvider.isNotEmpty) {
currentExt = searchProviders.where((e) => e.id == currentProvider).firstOrNull; currentExt = searchProviders.where((e) => e.id == currentProvider).firstOrNull;
@@ -1567,12 +1534,10 @@ class _SearchProviderDropdown extends ConsumerWidget {
if (currentExt != null) { if (currentExt != null) {
iconPath = currentExt.iconPath; iconPath = currentExt.iconPath;
if (currentExt.searchBehavior?.icon != null) { if (currentExt.searchBehavior?.icon != null) {
// Use search behavior icon if available
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!); displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
} }
} }
// Don't show dropdown if no custom search providers available
if (searchProviders.isEmpty) { if (searchProviders.isEmpty) {
return const Icon(Icons.search); return const Icon(Icons.search);
} }
@@ -1608,15 +1573,13 @@ class _SearchProviderDropdown extends ConsumerWidget {
offset: const Offset(0, 40), offset: const Offset(0, 40),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (String providerId) { onSelected: (String providerId) {
// Empty string means default (Deezer/Spotify)
final provider = providerId.isEmpty ? null : providerId; final provider = providerId.isEmpty ? null : providerId;
ref.read(settingsProvider.notifier).setSearchProvider(provider); ref.read(settingsProvider.notifier).setSearchProvider(provider);
onProviderChanged?.call(); onProviderChanged?.call();
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
// Default option (Deezer/Spotify based on metadata source)
PopupMenuItem<String>( PopupMenuItem<String>(
value: '', // Empty string = default provider value: '',
child: Row( child: Row(
children: [ children: [
Icon( Icon(
@@ -1716,7 +1679,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
} }
} }
/// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes
class _TrackItemWithStatus extends ConsumerWidget { class _TrackItemWithStatus extends ConsumerWidget {
final Track track; final Track track;
final int index; final int index;
@@ -2028,7 +1990,6 @@ class _CollectionItemWidget extends StatelessWidget {
} }
} }
/// Screen for viewing extension album with track fetching
class ExtensionAlbumScreen extends ConsumerStatefulWidget { class ExtensionAlbumScreen extends ConsumerStatefulWidget {
final String extensionId; final String extensionId;
final String albumId; final String albumId;
@@ -2299,7 +2260,6 @@ class _ExtensionPlaylistScreenState extends ConsumerState<ExtensionPlaylistScree
} }
} }
/// Screen for viewing extension artist with album fetching
class ExtensionArtistScreen extends ConsumerStatefulWidget { class ExtensionArtistScreen extends ConsumerStatefulWidget {
final String extensionId; final String extensionId;
final String artistId; final String artistId;
-6
View File
@@ -120,7 +120,6 @@ class _MainShellState extends ConsumerState<MainShell> {
} }
} }
/// Handle back press with double-tap to exit
void _handleBackPress() { void _handleBackPress() {
final trackState = ref.read(trackProvider); final trackState = ref.read(trackProvider);
@@ -174,9 +173,6 @@ class _MainShellState extends ConsumerState<MainShell> {
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
// Determine if we can pop (for predictive back animation)
// canPop is true when we're at root with no content - enables predictive back gesture
// IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation
final canPop = _currentIndex == 0 && final canPop = _currentIndex == 0 &&
!trackState.hasSearchText && !trackState.hasSearchText &&
!trackState.hasContent && !trackState.hasContent &&
@@ -250,8 +246,6 @@ class _MainShellState extends ConsumerState<MainShell> {
canPop: canPop, canPop: canPop,
onPopInvokedWithResult: (didPop, result) async { onPopInvokedWithResult: (didPop, result) async {
if (didPop) { if (didPop) {
// System handled the pop - this means predictive back completed
// We need to handle double-tap to exit here
return; return;
} }
-2
View File
@@ -11,7 +11,6 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
/// Playlist detail screen with Material Expressive 3 design
class PlaylistScreen extends ConsumerStatefulWidget { class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName; final String playlistName;
final String? coverUrl; final String? coverUrl;
@@ -69,7 +68,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}); });
} }
} catch (_) { } catch (_) {
// Ignore palette extraction errors
} }
} }
-10
View File
@@ -13,7 +13,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
/// Grouped album data for history display
class _GroupedAlbum { class _GroupedAlbum {
final String albumName; final String albumName;
final String artistName; final String artistName;
@@ -108,7 +107,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
); );
} }
/// Enter selection mode with initial item
void _enterSelectionMode(String itemId) { void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
setState(() { setState(() {
@@ -125,7 +123,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}); });
} }
/// Toggle item selection
void _toggleSelection(String itemId) { void _toggleSelection(String itemId) {
setState(() { setState(() {
if (_selectedIds.contains(itemId)) { if (_selectedIds.contains(itemId)) {
@@ -146,7 +143,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}); });
} }
/// Delete selected items
Future<void> _deleteSelected() async { Future<void> _deleteSelected() async {
final count = _selectedIds.length; final count = _selectedIds.length;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@@ -307,9 +303,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
); );
} }
/// Filter history items based on current filter mode
/// Album = track yang albumnya punya >1 track di history
/// Single = track yang albumnya cuma 1 track di history
List<DownloadHistoryItem> _filterHistoryItems( List<DownloadHistoryItem> _filterHistoryItems(
List<DownloadHistoryItem> items, List<DownloadHistoryItem> items,
String filterMode, String filterMode,
@@ -725,7 +718,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
); );
} }
/// Build content for each filter tab
Widget _buildFilterContent({ Widget _buildFilterContent({
required BuildContext context, required BuildContext context,
required ColorScheme colorScheme, required ColorScheme colorScheme,
@@ -931,7 +923,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
); );
} }
/// Build album grid item for grouped albums view
Widget _buildAlbumGridItem( Widget _buildAlbumGridItem(
BuildContext context, BuildContext context,
_GroupedAlbum album, _GroupedAlbum album,
@@ -1745,7 +1736,6 @@ child: CachedNetworkImage(
} }
} }
/// Filter chip widget for history filtering
class _FilterChip extends StatelessWidget { class _FilterChip extends StatelessWidget {
final String label; final String label;
final int count; final int count;
+3 -3
View File
@@ -15,7 +15,7 @@ class AboutPage extends StatelessWidget {
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true,
child: Scaffold( child: Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
@@ -253,9 +253,9 @@ class _AppHeaderCard extends StatelessWidget {
color: colorScheme.primary, color: colorScheme.primary,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Image.asset( child: Image.asset(
'assets/images/logo-transparant.png', 'assets/images/logo-transparant.png',
color: colorScheme.onPrimary, // Tint with onPrimary color color: colorScheme.onPrimary,
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect( errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
@@ -17,7 +17,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true,
child: Scaffold( child: Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
@@ -161,7 +161,7 @@ class _ThemePreviewCard extends StatelessWidget {
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme color: colorScheme
.surfaceContainerHighest, // Background similar to reference .surfaceContainerHighest,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -203,7 +203,7 @@ class _ThemePreviewCard extends StatelessWidget {
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.1), color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12, // Reduced from 20 for performance blurRadius: 12,
offset: const Offset(0, 8), offset: const Offset(0, 8),
), ),
], ],
@@ -22,7 +22,7 @@ class DownloadSettingsPage extends ConsumerWidget {
final isBuiltInService = _builtInServices.contains(settings.defaultService); final isBuiltInService = _builtInServices.contains(settings.defaultService);
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true,
child: Scaffold( child: Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
+1 -1
View File
@@ -581,7 +581,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
switch (step) { switch (step) {
case 0: return _storagePermissionGranted; case 0: return _storagePermissionGranted;
case 1: return _selectedDirectory != null; case 1: return _selectedDirectory != null;
case 2: return false; // Spotify step never shows checkmark (optional) case 2: return false;
} }
} }
return false; return false;
+1 -1
View File
@@ -122,7 +122,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
), ),
onChanged: (value) { onChanged: (value) {
ref.read(storeProvider.notifier).setSearchQuery(value); ref.read(storeProvider.notifier).setSearchQuery(value);
setState(() {}); // Update suffix icon setState(() {});
}, },
), ),
), ),
+2 -8
View File
@@ -13,8 +13,6 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
/// Screen to display detailed metadata for a downloaded track
/// Designed with Material Expressive 3 style
class TrackMetadataScreen extends ConsumerStatefulWidget { class TrackMetadataScreen extends ConsumerStatefulWidget {
final DownloadHistoryItem item; final DownloadHistoryItem item;
@@ -101,7 +99,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}); });
} }
} catch (_) { } catch (_) {
// Ignore palette extraction errors
} }
} }
@@ -263,7 +260,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Background with dominant color
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -280,7 +276,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
), ),
// Cover image centered - fade out when collapsing
AnimatedOpacity( AnimatedOpacity(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
@@ -683,7 +678,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
), ),
), ),
// Show 320kbps for MP3, bit depth/sample rate for FLAC
if (fileExtension == 'MP3') if (fileExtension == 'MP3')
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
@@ -1057,8 +1051,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); // Close dialog Navigator.pop(context);
Navigator.pop(context); // Go back to history Navigator.pop(context);
} }
}, },
child: Text(context.l10n.dialogDelete, style: TextStyle(color: colorScheme.error)), child: Text(context.l10n.dialogDelete, style: TextStyle(color: colorScheme.error)),
-6
View File
@@ -18,8 +18,6 @@ class CoverCacheManager {
static bool _initialized = false; static bool _initialized = false;
static String? _cachePath; static String? _cachePath;
/// Get the singleton cache manager instance.
/// Must call [initialize] before using this.
static CacheManager get instance { static CacheManager get instance {
if (!_initialized || _instance == null) { if (!_initialized || _instance == null) {
// Fallback to default cache manager if not initialized // Fallback to default cache manager if not initialized
@@ -32,8 +30,6 @@ class CoverCacheManager {
/// Check if cache manager is initialized /// Check if cache manager is initialized
static bool get isInitialized => _initialized && _instance != null; static bool get isInitialized => _initialized && _instance != null;
/// Initialize the cache manager with persistent storage path.
/// Call this once during app startup (in main.dart).
static Future<void> initialize() async { static Future<void> initialize() async {
if (_initialized) return; if (_initialized) return;
@@ -73,7 +69,6 @@ class CoverCacheManager {
await _instance!.emptyCache(); await _instance!.emptyCache();
} }
/// Get cache statistics
static Future<CacheStats> getStats() async { static Future<CacheStats> getStats() async {
if (!_initialized || _cachePath == null) { if (!_initialized || _cachePath == null) {
return const CacheStats(fileCount: 0, totalSizeBytes: 0); return const CacheStats(fileCount: 0, totalSizeBytes: 0);
@@ -113,7 +108,6 @@ class CacheStats {
required this.totalSizeBytes, required this.totalSizeBytes,
}); });
/// Get human-readable size string
String get formattedSize { String get formattedSize {
if (totalSizeBytes < 1024) { if (totalSizeBytes < 1024) {
return '$totalSizeBytes B'; return '$totalSizeBytes B';
-4
View File
@@ -7,8 +7,6 @@ import 'package:spotiflac_android/utils/logger.dart';
class CsvImportService { class CsvImportService {
static final _log = AppLogger('CsvImportService'); static final _log = AppLogger('CsvImportService');
/// Pick and parse CSV file, then enrich metadata from Deezer
/// [onProgress] callback receives (current, total) for progress updates
static Future<List<Track>> pickAndParseCsv({ static Future<List<Track>> pickAndParseCsv({
void Function(int current, int total)? onProgress, void Function(int current, int total)? onProgress,
}) async { }) async {
@@ -34,8 +32,6 @@ class CsvImportService {
return []; return [];
} }
/// Enrich tracks with metadata from Deezer using ISRC or search
/// This fetches cover URL, duration, and other metadata that CSV doesn't have
static Future<List<Track>> _enrichTracksMetadata( static Future<List<Track>> _enrichTracksMetadata(
List<Track> tracks, { List<Track> tracks, {
void Function(int current, int total)? onProgress, void Function(int current, int total)? onProgress,
+1 -18
View File
@@ -10,7 +10,6 @@ final _log = AppLogger('FFmpeg');
class FFmpegService { class FFmpegService {
static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg'); static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg');
/// Execute FFmpeg command and return result
static Future<FFmpegResult> _execute(String command) async { static Future<FFmpegResult> _execute(String command) async {
try { try {
final result = await _channel.invokeMethod('execute', {'command': command}); final result = await _channel.invokeMethod('execute', {'command': command});
@@ -26,8 +25,6 @@ class FFmpegService {
} }
} }
/// Convert M4A (DASH segments) to FLAC
/// Returns the output file path on success, null on failure
static Future<String?> convertM4aToFlac(String inputPath) async { static Future<String?> convertM4aToFlac(String inputPath) async {
final outputPath = inputPath.replaceAll('.m4a', '.flac'); final outputPath = inputPath.replaceAll('.m4a', '.flac');
@@ -47,14 +44,11 @@ class FFmpegService {
return null; return null;
} }
/// Convert FLAC to MP3
/// If deleteOriginal is true, deletes the FLAC file after conversion
static Future<String?> convertFlacToMp3( static Future<String?> convertFlacToMp3(
String inputPath, { String inputPath, {
String bitrate = '320k', String bitrate = '320k',
bool deleteOriginal = true, bool deleteOriginal = true,
}) async { }) async {
// Convert in same folder, just change extension
final outputPath = inputPath.replaceAll('.flac', '.mp3'); final outputPath = inputPath.replaceAll('.flac', '.mp3');
final command = final command =
@@ -63,7 +57,6 @@ class FFmpegService {
final result = await _execute(command); final result = await _execute(command);
if (result.success) { if (result.success) {
// Delete original FLAC if requested
if (deleteOriginal) { if (deleteOriginal) {
try { try {
await File(inputPath).delete(); await File(inputPath).delete();
@@ -76,7 +69,6 @@ class FFmpegService {
return null; return null;
} }
/// Convert FLAC to M4A (AAC or ALAC)
static Future<String?> convertFlacToM4a( static Future<String?> convertFlacToM4a(
String inputPath, { String inputPath, {
String codec = 'aac', String codec = 'aac',
@@ -110,7 +102,6 @@ class FFmpegService {
return null; return null;
} }
/// Check if FFmpeg is available
static Future<bool> isAvailable() async { static Future<bool> isAvailable() async {
try { try {
final version = await _channel.invokeMethod('getVersion'); final version = await _channel.invokeMethod('getVersion');
@@ -120,7 +111,6 @@ class FFmpegService {
} }
} }
/// Get FFmpeg version info
static Future<String?> getVersion() async { static Future<String?> getVersion() async {
try { try {
final version = await _channel.invokeMethod('getVersion'); final version = await _channel.invokeMethod('getVersion');
@@ -130,8 +120,6 @@ class FFmpegService {
} }
} }
/// Embed metadata and cover art to FLAC file
/// Returns the file path on success, null on failure
static Future<String?> embedMetadata({ static Future<String?> embedMetadata({
required String flacPath, required String flacPath,
String? coverPath, String? coverPath,
@@ -211,8 +199,6 @@ class FFmpegService {
return null; return null;
} }
/// Embed metadata and cover art to MP3 file using ID3v2 tags
/// Returns the file path on success, null on failure
static Future<String?> embedMetadataToMp3({ static Future<String?> embedMetadataToMp3({
required String mp3Path, required String mp3Path,
String? coverPath, String? coverPath,
@@ -242,7 +228,6 @@ class FFmpegService {
cmdBuffer.write('-c:a copy '); cmdBuffer.write('-c:a copy ');
if (metadata != null) { if (metadata != null) {
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
final id3Metadata = _convertToId3Tags(metadata); final id3Metadata = _convertToId3Tags(metadata);
id3Metadata.forEach((key, value) { id3Metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"'); final sanitizedValue = value.replaceAll('"', '\\"');
@@ -295,7 +280,6 @@ class FFmpegService {
return null; return null;
} }
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) { static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
final id3Map = <String, String>{}; final id3Map = <String, String>{};
@@ -330,7 +314,7 @@ class FFmpegService {
id3Map['date'] = value; id3Map['date'] = value;
break; break;
case 'ISRC': case 'ISRC':
id3Map['TSRC'] = value; // ID3v2 ISRC frame id3Map['TSRC'] = value;
break; break;
case 'LYRICS': case 'LYRICS':
case 'UNSYNCEDLYRICS': case 'UNSYNCEDLYRICS':
@@ -346,7 +330,6 @@ class FFmpegService {
} }
} }
/// Result of FFmpeg command execution
class FFmpegResult { class FFmpegResult {
final bool success; final bool success;
final int returnCode; final int returnCode;
+2 -122
View File
@@ -4,25 +4,21 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('PlatformBridge'); final _log = AppLogger('PlatformBridge');
/// Bridge to communicate with Go backend via platform channels
class PlatformBridge { class PlatformBridge {
static const _channel = MethodChannel('com.zarz.spotiflac/backend'); static const _channel = MethodChannel('com.zarz.spotiflac/backend');
/// Parse and validate Spotify URL
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async { static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
_log.d('parseSpotifyUrl: $url'); _log.d('parseSpotifyUrl: $url');
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url}); final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get Spotify metadata from URL
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async { static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
_log.d('getSpotifyMetadata: $url'); _log.d('getSpotifyMetadata: $url');
final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url}); final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Search Spotify
static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async { static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async {
_log.d('searchSpotify: "$query" (limit: $limit)'); _log.d('searchSpotify: "$query" (limit: $limit)');
final result = await _channel.invokeMethod('searchSpotify', { final result = await _channel.invokeMethod('searchSpotify', {
@@ -32,7 +28,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Search Spotify for tracks and artists
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
_log.d('searchSpotifyAll: "$query"'); _log.d('searchSpotifyAll: "$query"');
final result = await _channel.invokeMethod('searchSpotifyAll', { final result = await _channel.invokeMethod('searchSpotifyAll', {
@@ -43,7 +38,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Check track availability on streaming services
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async { static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)'); _log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
final result = await _channel.invokeMethod('checkAvailability', { final result = await _channel.invokeMethod('checkAvailability', {
@@ -53,7 +47,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Download a track from specific service
static Future<Map<String, dynamic>> downloadTrack({ static Future<Map<String, dynamic>> downloadTrack({
required String isrc, required String isrc,
required String service, required String service,
@@ -108,7 +101,6 @@ class PlatformBridge {
return response; return response;
} }
/// Download with automatic fallback to other services
static Future<Map<String, dynamic>> downloadWithFallback({ static Future<Map<String, dynamic>> downloadWithFallback({
required String isrc, required String isrc,
required String spotifyId, required String spotifyId,
@@ -129,11 +121,9 @@ class PlatformBridge {
String preferredService = 'tidal', String preferredService = 'tidal',
String? itemId, String? itemId,
int durationMs = 0, int durationMs = 0,
// Extended metadata for FLAC tagging
String? genre, String? genre,
String? label, String? label,
String? copyright, String? copyright,
// Lyrics mode: "embed" (default), "external" (.lrc file), "both"
String lyricsMode = 'embed', String lyricsMode = 'embed',
}) async { }) async {
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)'); _log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
@@ -157,11 +147,9 @@ class PlatformBridge {
'release_date': releaseDate ?? '', 'release_date': releaseDate ?? '',
'item_id': itemId ?? '', 'item_id': itemId ?? '',
'duration_ms': durationMs, 'duration_ms': durationMs,
// Extended metadata
'genre': genre ?? '', 'genre': genre ?? '',
'label': label ?? '', 'label': label ?? '',
'copyright': copyright ?? '', 'copyright': copyright ?? '',
// Lyrics mode
'lyrics_mode': lyricsMode, 'lyrics_mode': lyricsMode,
}); });
@@ -184,44 +172,36 @@ class PlatformBridge {
return response; return response;
} }
/// Get download progress (legacy single download)
static Future<Map<String, dynamic>> getDownloadProgress() async { static Future<Map<String, dynamic>> getDownloadProgress() async {
final result = await _channel.invokeMethod('getDownloadProgress'); final result = await _channel.invokeMethod('getDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get progress for all active downloads (concurrent mode)
static Future<Map<String, dynamic>> getAllDownloadProgress() async { static Future<Map<String, dynamic>> getAllDownloadProgress() async {
final result = await _channel.invokeMethod('getAllDownloadProgress'); final result = await _channel.invokeMethod('getAllDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Initialize progress tracking for a download item
static Future<void> initItemProgress(String itemId) async { static Future<void> initItemProgress(String itemId) async {
await _channel.invokeMethod('initItemProgress', {'item_id': itemId}); await _channel.invokeMethod('initItemProgress', {'item_id': itemId});
} }
/// Finish progress tracking for a download item
static Future<void> finishItemProgress(String itemId) async { static Future<void> finishItemProgress(String itemId) async {
await _channel.invokeMethod('finishItemProgress', {'item_id': itemId}); await _channel.invokeMethod('finishItemProgress', {'item_id': itemId});
} }
/// Clear progress tracking for a download item
static Future<void> clearItemProgress(String itemId) async { static Future<void> clearItemProgress(String itemId) async {
await _channel.invokeMethod('clearItemProgress', {'item_id': itemId}); await _channel.invokeMethod('clearItemProgress', {'item_id': itemId});
} }
/// Cancel an in-progress download
static Future<void> cancelDownload(String itemId) async { static Future<void> cancelDownload(String itemId) async {
await _channel.invokeMethod('cancelDownload', {'item_id': itemId}); await _channel.invokeMethod('cancelDownload', {'item_id': itemId});
} }
/// Set download directory
static Future<void> setDownloadDirectory(String path) async { static Future<void> setDownloadDirectory(String path) async {
await _channel.invokeMethod('setDownloadDirectory', {'path': path}); await _channel.invokeMethod('setDownloadDirectory', {'path': path});
} }
/// Check if file with ISRC already exists
static Future<Map<String, dynamic>> checkDuplicate(String outputDir, String isrc) async { static Future<Map<String, dynamic>> checkDuplicate(String outputDir, String isrc) async {
final result = await _channel.invokeMethod('checkDuplicate', { final result = await _channel.invokeMethod('checkDuplicate', {
'output_dir': outputDir, 'output_dir': outputDir,
@@ -230,7 +210,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Build filename from template
static Future<String> buildFilename(String template, Map<String, dynamic> metadata) async { static Future<String> buildFilename(String template, Map<String, dynamic> metadata) async {
final result = await _channel.invokeMethod('buildFilename', { final result = await _channel.invokeMethod('buildFilename', {
'template': template, 'template': template,
@@ -239,7 +218,6 @@ class PlatformBridge {
return result as String; return result as String;
} }
/// Sanitize filename
static Future<String> sanitizeFilename(String filename) async { static Future<String> sanitizeFilename(String filename) async {
final result = await _channel.invokeMethod('sanitizeFilename', { final result = await _channel.invokeMethod('sanitizeFilename', {
'filename': filename, 'filename': filename,
@@ -247,8 +225,6 @@ class PlatformBridge {
return result as String; return result as String;
} }
/// Fetch lyrics for a track
/// [durationMs] is the track duration in milliseconds for better matching
static Future<Map<String, dynamic>> fetchLyrics( static Future<Map<String, dynamic>> fetchLyrics(
String spotifyId, String spotifyId,
String trackName, String trackName,
@@ -264,9 +240,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get lyrics in LRC format
/// First tries to extract from embedded file, then falls back to internet
/// [durationMs] is the track duration in milliseconds for better matching
static Future<String> getLyricsLRC( static Future<String> getLyricsLRC(
String spotifyId, String spotifyId,
String trackName, String trackName,
@@ -284,7 +257,6 @@ class PlatformBridge {
return result as String; return result as String;
} }
/// Embed lyrics into an existing FLAC file
static Future<Map<String, dynamic>> embedLyricsToFile( static Future<Map<String, dynamic>> embedLyricsToFile(
String filePath, String filePath,
String lyrics, String lyrics,
@@ -296,15 +268,10 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Cleanup idle HTTP connections to prevent TCP exhaustion
/// Call this periodically during large batch downloads
static Future<void> cleanupConnections() async { static Future<void> cleanupConnections() async {
await _channel.invokeMethod('cleanupConnections'); await _channel.invokeMethod('cleanupConnections');
} }
/// Read metadata directly from a FLAC file
/// Returns all embedded metadata (title, artist, album, track number, etc.)
/// This reads from the actual file, not from cached/database data
static Future<Map<String, dynamic>> readFileMetadata(String filePath) async { static Future<Map<String, dynamic>> readFileMetadata(String filePath) async {
final result = await _channel.invokeMethod('readFileMetadata', { final result = await _channel.invokeMethod('readFileMetadata', {
'file_path': filePath, 'file_path': filePath,
@@ -312,7 +279,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Start foreground download service to keep downloads running in background
static Future<void> startDownloadService({ static Future<void> startDownloadService({
String trackName = '', String trackName = '',
String artistName = '', String artistName = '',
@@ -325,12 +291,10 @@ class PlatformBridge {
}); });
} }
/// Stop foreground download service
static Future<void> stopDownloadService() async { static Future<void> stopDownloadService() async {
await _channel.invokeMethod('stopDownloadService'); await _channel.invokeMethod('stopDownloadService');
} }
/// Update download service notification progress
static Future<void> updateDownloadServiceProgress({ static Future<void> updateDownloadServiceProgress({
required String trackName, required String trackName,
required String artistName, required String artistName,
@@ -347,13 +311,11 @@ class PlatformBridge {
}); });
} }
/// Check if download service is running
static Future<bool> isDownloadServiceRunning() async { static Future<bool> isDownloadServiceRunning() async {
final result = await _channel.invokeMethod('isDownloadServiceRunning'); final result = await _channel.invokeMethod('isDownloadServiceRunning');
return result as bool; return result as bool;
} }
/// Set custom Spotify API credentials
static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async { static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
await _channel.invokeMethod('setSpotifyCredentials', { await _channel.invokeMethod('setSpotifyCredentials', {
'client_id': clientId, 'client_id': clientId,
@@ -361,35 +323,26 @@ class PlatformBridge {
}); });
} }
/// Check if Spotify credentials are configured
/// Returns true if credentials are available (custom or env vars) /// 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;
} }
/// Pre-warm track ID cache for album/playlist tracks
/// This runs in background and returns immediately
/// Speeds up subsequent downloads by caching ISRC → Track ID mappings
static Future<void> preWarmTrackCache(List<Map<String, String>> tracks) async { static Future<void> preWarmTrackCache(List<Map<String, String>> tracks) async {
final tracksJson = jsonEncode(tracks); final tracksJson = jsonEncode(tracks);
await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson}); await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson});
} }
/// Get current track cache size
static Future<int> getTrackCacheSize() async { static Future<int> getTrackCacheSize() async {
final result = await _channel.invokeMethod('getTrackCacheSize'); final result = await _channel.invokeMethod('getTrackCacheSize');
return result as int; return result as int;
} }
/// Clear track ID cache
static Future<void> clearTrackCache() async { static Future<void> clearTrackCache() async {
await _channel.invokeMethod('clearTrackCache'); await _channel.invokeMethod('clearTrackCache');
} }
// ==================== DEEZER API ====================
/// Search Deezer for tracks and artists (no API key required)
static Future<Map<String, dynamic>> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { static Future<Map<String, dynamic>> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
final result = await _channel.invokeMethod('searchDeezerAll', { final result = await _channel.invokeMethod('searchDeezerAll', {
'query': query, 'query': query,
@@ -399,7 +352,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get Deezer metadata by type and ID
static Future<Map<String, dynamic>> getDeezerMetadata(String resourceType, String resourceId) async { static Future<Map<String, dynamic>> getDeezerMetadata(String resourceType, String resourceId) async {
final result = await _channel.invokeMethod('getDeezerMetadata', { final result = await _channel.invokeMethod('getDeezerMetadata', {
'resource_type': resourceType, 'resource_type': resourceType,
@@ -411,20 +363,16 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Parse Deezer URL and return type and ID
static Future<Map<String, dynamic>> parseDeezerUrl(String url) async { static Future<Map<String, dynamic>> parseDeezerUrl(String url) async {
final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url}); final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Search Deezer by ISRC
static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async { static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async {
final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc}); final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc});
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get extended metadata (genre, label) from Deezer using track ID
/// Returns {"genre": "...", "label": "..."} or null if not found
static Future<Map<String, String>?> getDeezerExtendedMetadata(String trackId) async { static Future<Map<String, String>?> getDeezerExtendedMetadata(String trackId) async {
try { try {
final result = await _channel.invokeMethod('getDeezerExtendedMetadata', { final result = await _channel.invokeMethod('getDeezerExtendedMetadata', {
@@ -442,7 +390,6 @@ class PlatformBridge {
} }
} }
/// Convert Spotify track to Deezer and get metadata (for rate limit fallback)
static Future<Map<String, dynamic>> convertSpotifyToDeezer(String resourceType, String spotifyId) async { static Future<Map<String, dynamic>> convertSpotifyToDeezer(String resourceType, String spotifyId) async {
final result = await _channel.invokeMethod('convertSpotifyToDeezer', { final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
'resource_type': resourceType, 'resource_type': resourceType,
@@ -451,15 +398,11 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get Spotify metadata with automatic Deezer fallback on rate limit
static Future<Map<String, dynamic>> getSpotifyMetadataWithFallback(String url) async { static Future<Map<String, dynamic>> getSpotifyMetadataWithFallback(String url) async {
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url}); final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
// ==================== GO BACKEND LOGS ====================
/// Get all logs from Go backend
static Future<List<Map<String, dynamic>>> getGoLogs() async { static Future<List<Map<String, dynamic>>> getGoLogs() async {
final result = await _channel.invokeMethod('getLogs'); final result = await _channel.invokeMethod('getLogs');
final logs = jsonDecode(result as String) as List<dynamic>; final logs = jsonDecode(result as String) as List<dynamic>;
@@ -472,25 +415,20 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Clear Go backend logs
static Future<void> clearGoLogs() async { static Future<void> clearGoLogs() async {
await _channel.invokeMethod('clearLogs'); await _channel.invokeMethod('clearLogs');
} }
/// Get Go backend log count
static Future<int> getGoLogCount() async { static Future<int> getGoLogCount() async {
final result = await _channel.invokeMethod('getLogCount'); final result = await _channel.invokeMethod('getLogCount');
return result as int; return result as int;
} }
/// Enable or disable Go backend logging
static Future<void> setGoLoggingEnabled(bool enabled) async { static Future<void> setGoLoggingEnabled(bool enabled) async {
await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled}); await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled});
} }
// ==================== EXTENSION SYSTEM ====================
/// Initialize the extension system
static Future<void> initExtensionSystem(String extensionsDir, String dataDir) async { static Future<void> initExtensionSystem(String extensionsDir, String dataDir) async {
_log.d('initExtensionSystem: $extensionsDir, $dataDir'); _log.d('initExtensionSystem: $extensionsDir, $dataDir');
await _channel.invokeMethod('initExtensionSystem', { await _channel.invokeMethod('initExtensionSystem', {
@@ -499,7 +437,6 @@ class PlatformBridge {
}); });
} }
/// Load all extensions from directory
static Future<Map<String, dynamic>> loadExtensionsFromDir(String dirPath) async { static Future<Map<String, dynamic>> loadExtensionsFromDir(String dirPath) async {
_log.d('loadExtensionsFromDir: $dirPath'); _log.d('loadExtensionsFromDir: $dirPath');
final result = await _channel.invokeMethod('loadExtensionsFromDir', { final result = await _channel.invokeMethod('loadExtensionsFromDir', {
@@ -508,7 +445,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Load a single extension from file
static Future<Map<String, dynamic>> loadExtensionFromPath(String filePath) async { static Future<Map<String, dynamic>> loadExtensionFromPath(String filePath) async {
_log.d('loadExtensionFromPath: $filePath'); _log.d('loadExtensionFromPath: $filePath');
final result = await _channel.invokeMethod('loadExtensionFromPath', { final result = await _channel.invokeMethod('loadExtensionFromPath', {
@@ -517,7 +453,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Unload an extension
static Future<void> unloadExtension(String extensionId) async { static Future<void> unloadExtension(String extensionId) async {
_log.d('unloadExtension: $extensionId'); _log.d('unloadExtension: $extensionId');
await _channel.invokeMethod('unloadExtension', { await _channel.invokeMethod('unloadExtension', {
@@ -525,7 +460,6 @@ class PlatformBridge {
}); });
} }
/// Remove an extension completely (unload + delete files)
static Future<void> removeExtension(String extensionId) async { static Future<void> removeExtension(String extensionId) async {
_log.d('removeExtension: $extensionId'); _log.d('removeExtension: $extensionId');
await _channel.invokeMethod('removeExtension', { await _channel.invokeMethod('removeExtension', {
@@ -533,7 +467,6 @@ class PlatformBridge {
}); });
} }
/// Upgrade an existing extension from a new package file
static Future<Map<String, dynamic>> upgradeExtension(String filePath) async { static Future<Map<String, dynamic>> upgradeExtension(String filePath) async {
_log.d('upgradeExtension: $filePath'); _log.d('upgradeExtension: $filePath');
final result = await _channel.invokeMethod('upgradeExtension', { final result = await _channel.invokeMethod('upgradeExtension', {
@@ -542,7 +475,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Check if a package file is an upgrade for an existing extension
static Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async { static Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
_log.d('checkExtensionUpgrade: $filePath'); _log.d('checkExtensionUpgrade: $filePath');
final result = await _channel.invokeMethod('checkExtensionUpgrade', { final result = await _channel.invokeMethod('checkExtensionUpgrade', {
@@ -551,14 +483,12 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get all installed extensions
static Future<List<Map<String, dynamic>>> getInstalledExtensions() async { static Future<List<Map<String, dynamic>>> getInstalledExtensions() async {
final result = await _channel.invokeMethod('getInstalledExtensions'); final result = await _channel.invokeMethod('getInstalledExtensions');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Enable or disable an extension
static Future<void> setExtensionEnabled(String extensionId, bool enabled) async { static Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
_log.d('setExtensionEnabled: $extensionId = $enabled'); _log.d('setExtensionEnabled: $extensionId = $enabled');
await _channel.invokeMethod('setExtensionEnabled', { await _channel.invokeMethod('setExtensionEnabled', {
@@ -567,7 +497,6 @@ class PlatformBridge {
}); });
} }
/// Set provider priority order
static Future<void> setProviderPriority(List<String> providerIds) async { static Future<void> setProviderPriority(List<String> providerIds) async {
_log.d('setProviderPriority: $providerIds'); _log.d('setProviderPriority: $providerIds');
await _channel.invokeMethod('setProviderPriority', { await _channel.invokeMethod('setProviderPriority', {
@@ -575,14 +504,12 @@ class PlatformBridge {
}); });
} }
/// Get provider priority order
static Future<List<String>> getProviderPriority() async { static Future<List<String>> getProviderPriority() async {
final result = await _channel.invokeMethod('getProviderPriority'); final result = await _channel.invokeMethod('getProviderPriority');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as String).toList(); return list.map((e) => e as String).toList();
} }
/// Set metadata provider priority order
static Future<void> setMetadataProviderPriority(List<String> providerIds) async { static Future<void> setMetadataProviderPriority(List<String> providerIds) async {
_log.d('setMetadataProviderPriority: $providerIds'); _log.d('setMetadataProviderPriority: $providerIds');
await _channel.invokeMethod('setMetadataProviderPriority', { await _channel.invokeMethod('setMetadataProviderPriority', {
@@ -590,14 +517,12 @@ class PlatformBridge {
}); });
} }
/// Get metadata provider priority order
static Future<List<String>> getMetadataProviderPriority() async { static Future<List<String>> getMetadataProviderPriority() async {
final result = await _channel.invokeMethod('getMetadataProviderPriority'); final result = await _channel.invokeMethod('getMetadataProviderPriority');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as String).toList(); return list.map((e) => e as String).toList();
} }
/// Get extension settings
static Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async { static Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
final result = await _channel.invokeMethod('getExtensionSettings', { final result = await _channel.invokeMethod('getExtensionSettings', {
'extension_id': extensionId, 'extension_id': extensionId,
@@ -605,7 +530,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Set extension settings
static Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async { static Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
_log.d('setExtensionSettings: $extensionId'); _log.d('setExtensionSettings: $extensionId');
await _channel.invokeMethod('setExtensionSettings', { await _channel.invokeMethod('setExtensionSettings', {
@@ -614,8 +538,6 @@ class PlatformBridge {
}); });
} }
/// Invoke an action on an extension (e.g., button click handler like "startLogin")
/// Returns the result from the JS function
static Future<Map<String, dynamic>> invokeExtensionAction(String extensionId, String actionName) async { static Future<Map<String, dynamic>> invokeExtensionAction(String extensionId, String actionName) async {
_log.d('invokeExtensionAction: $extensionId.$actionName'); _log.d('invokeExtensionAction: $extensionId.$actionName');
final result = await _channel.invokeMethod('invokeExtensionAction', { final result = await _channel.invokeMethod('invokeExtensionAction', {
@@ -628,7 +550,6 @@ class PlatformBridge {
return jsonDecode(result) as Map<String, dynamic>; return jsonDecode(result) as Map<String, dynamic>;
} }
/// Search tracks using extension providers
static Future<List<Map<String, dynamic>>> searchTracksWithExtensions(String query, {int limit = 20}) async { static Future<List<Map<String, dynamic>>> searchTracksWithExtensions(String query, {int limit = 20}) async {
_log.d('searchTracksWithExtensions: "$query"'); _log.d('searchTracksWithExtensions: "$query"');
final result = await _channel.invokeMethod('searchTracksWithExtensions', { final result = await _channel.invokeMethod('searchTracksWithExtensions', {
@@ -639,7 +560,6 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Download with extension providers (includes fallback)
static Future<Map<String, dynamic>> downloadWithExtensions({ static Future<Map<String, dynamic>> downloadWithExtensions({
required String isrc, required String isrc,
required String spotifyId, required String spotifyId,
@@ -659,10 +579,9 @@ class PlatformBridge {
String? releaseDate, String? releaseDate,
String? itemId, String? itemId,
int durationMs = 0, int durationMs = 0,
String? source, // Extension ID that provided this track (prioritize this extension) String? source,
String? genre, String? genre,
String? label, String? label,
// Lyrics mode: "embed" (default), "external" (.lrc file), "both"
String lyricsMode = 'embed', String lyricsMode = 'embed',
}) async { }) async {
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}'); _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}');
@@ -685,10 +604,9 @@ class PlatformBridge {
'release_date': releaseDate ?? '', 'release_date': releaseDate ?? '',
'item_id': itemId ?? '', 'item_id': itemId ?? '',
'duration_ms': durationMs, 'duration_ms': durationMs,
'source': source ?? '', // Extension ID that provided this track 'source': source ?? '',
'genre': genre ?? '', 'genre': genre ?? '',
'label': label ?? '', 'label': label ?? '',
// Lyrics mode
'lyrics_mode': lyricsMode, 'lyrics_mode': lyricsMode,
}); });
@@ -696,15 +614,11 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Cleanup all extensions (call on app close)
static Future<void> cleanupExtensions() async { static Future<void> cleanupExtensions() async {
_log.d('cleanupExtensions'); _log.d('cleanupExtensions');
await _channel.invokeMethod('cleanupExtensions'); await _channel.invokeMethod('cleanupExtensions');
} }
// ==================== EXTENSION AUTH API ====================
/// Get pending auth request for an extension (if any)
static Future<Map<String, dynamic>?> getExtensionPendingAuth(String extensionId) async { static Future<Map<String, dynamic>?> getExtensionPendingAuth(String extensionId) async {
final result = await _channel.invokeMethod('getExtensionPendingAuth', { final result = await _channel.invokeMethod('getExtensionPendingAuth', {
'extension_id': extensionId, 'extension_id': extensionId,
@@ -713,7 +627,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Set auth code for an extension (after OAuth callback)
static Future<void> setExtensionAuthCode(String extensionId, String authCode) async { static Future<void> setExtensionAuthCode(String extensionId, String authCode) async {
_log.d('setExtensionAuthCode: $extensionId'); _log.d('setExtensionAuthCode: $extensionId');
await _channel.invokeMethod('setExtensionAuthCode', { await _channel.invokeMethod('setExtensionAuthCode', {
@@ -722,7 +635,6 @@ class PlatformBridge {
}); });
} }
/// Set tokens for an extension (after token exchange)
static Future<void> setExtensionTokens( static Future<void> setExtensionTokens(
String extensionId, { String extensionId, {
required String accessToken, required String accessToken,
@@ -738,14 +650,12 @@ class PlatformBridge {
}); });
} }
/// Clear pending auth request for an extension
static Future<void> clearExtensionPendingAuth(String extensionId) async { static Future<void> clearExtensionPendingAuth(String extensionId) async {
await _channel.invokeMethod('clearExtensionPendingAuth', { await _channel.invokeMethod('clearExtensionPendingAuth', {
'extension_id': extensionId, 'extension_id': extensionId,
}); });
} }
/// Check if extension is authenticated
static Future<bool> isExtensionAuthenticated(String extensionId) async { static Future<bool> isExtensionAuthenticated(String extensionId) async {
final result = await _channel.invokeMethod('isExtensionAuthenticated', { final result = await _channel.invokeMethod('isExtensionAuthenticated', {
'extension_id': extensionId, 'extension_id': extensionId,
@@ -753,16 +663,12 @@ class PlatformBridge {
return result as bool; return result as bool;
} }
/// Get all pending auth requests (for polling)
static Future<List<Map<String, dynamic>>> getAllPendingAuthRequests() async { static Future<List<Map<String, dynamic>>> getAllPendingAuthRequests() async {
final result = await _channel.invokeMethod('getAllPendingAuthRequests'); final result = await _channel.invokeMethod('getAllPendingAuthRequests');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
// ==================== EXTENSION FFMPEG API ====================
/// Get pending FFmpeg command for execution
static Future<Map<String, dynamic>?> getPendingFFmpegCommand(String commandId) async { static Future<Map<String, dynamic>?> getPendingFFmpegCommand(String commandId) async {
final result = await _channel.invokeMethod('getPendingFFmpegCommand', { final result = await _channel.invokeMethod('getPendingFFmpegCommand', {
'command_id': commandId, 'command_id': commandId,
@@ -771,7 +677,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Set FFmpeg command result
static Future<void> setFFmpegCommandResult( static Future<void> setFFmpegCommandResult(
String commandId, { String commandId, {
required bool success, required bool success,
@@ -786,16 +691,12 @@ class PlatformBridge {
}); });
} }
/// Get all pending FFmpeg commands
static Future<List<Map<String, dynamic>>> getAllPendingFFmpegCommands() async { static Future<List<Map<String, dynamic>>> getAllPendingFFmpegCommands() async {
final result = await _channel.invokeMethod('getAllPendingFFmpegCommands'); final result = await _channel.invokeMethod('getAllPendingFFmpegCommands');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
// ==================== EXTENSION CUSTOM SEARCH ====================
/// Perform custom search using an extension
static Future<List<Map<String, dynamic>>> customSearchWithExtension( static Future<List<Map<String, dynamic>>> customSearchWithExtension(
String extensionId, String extensionId,
String query, { String query, {
@@ -810,17 +711,12 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Get all extensions that provide custom search
static Future<List<Map<String, dynamic>>> getSearchProviders() async { static Future<List<Map<String, dynamic>>> getSearchProviders() async {
final result = await _channel.invokeMethod('getSearchProviders'); final result = await _channel.invokeMethod('getSearchProviders');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
// ==================== EXTENSION URL HANDLER ====================
/// Handle a URL with any matching extension
/// Returns null if no extension can handle the URL
static Future<Map<String, dynamic>?> handleURLWithExtension(String url) async { static Future<Map<String, dynamic>?> handleURLWithExtension(String url) async {
try { try {
final result = await _channel.invokeMethod('handleURLWithExtension', { final result = await _channel.invokeMethod('handleURLWithExtension', {
@@ -833,8 +729,6 @@ class PlatformBridge {
} }
} }
/// Find an extension that can handle the given URL
/// Returns extension ID or null if none found
static Future<String?> findURLHandler(String url) async { static Future<String?> findURLHandler(String url) async {
final result = await _channel.invokeMethod('findURLHandler', { final result = await _channel.invokeMethod('findURLHandler', {
'url': url, 'url': url,
@@ -843,14 +737,12 @@ class PlatformBridge {
return result as String; return result as String;
} }
/// Get all extensions that handle custom URLs
static Future<List<Map<String, dynamic>>> getURLHandlers() async { static Future<List<Map<String, dynamic>>> getURLHandlers() async {
final result = await _channel.invokeMethod('getURLHandlers'); final result = await _channel.invokeMethod('getURLHandlers');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Get album tracks using an extension
static Future<Map<String, dynamic>?> getAlbumWithExtension( static Future<Map<String, dynamic>?> getAlbumWithExtension(
String extensionId, String extensionId,
String albumId, String albumId,
@@ -868,7 +760,6 @@ class PlatformBridge {
} }
} }
/// Get playlist tracks using an extension
static Future<Map<String, dynamic>?> getPlaylistWithExtension( static Future<Map<String, dynamic>?> getPlaylistWithExtension(
String extensionId, String extensionId,
String playlistId, String playlistId,
@@ -886,7 +777,6 @@ class PlatformBridge {
} }
} }
/// Get artist info and albums using an extension
static Future<Map<String, dynamic>?> getArtistWithExtension( static Future<Map<String, dynamic>?> getArtistWithExtension(
String extensionId, String extensionId,
String artistId, String artistId,
@@ -904,9 +794,7 @@ class PlatformBridge {
} }
} }
// ==================== EXTENSION POST-PROCESSING ====================
/// Run post-processing hooks on a file
static Future<Map<String, dynamic>> runPostProcessing( static Future<Map<String, dynamic>> runPostProcessing(
String filePath, { String filePath, {
Map<String, dynamic>? metadata, Map<String, dynamic>? metadata,
@@ -918,22 +806,18 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get all extensions that provide post-processing
static Future<List<Map<String, dynamic>>> getPostProcessingProviders() async { static Future<List<Map<String, dynamic>>> getPostProcessingProviders() async {
final result = await _channel.invokeMethod('getPostProcessingProviders'); final result = await _channel.invokeMethod('getPostProcessingProviders');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
// ==================== EXTENSION STORE ====================
/// Initialize extension store
static Future<void> initExtensionStore(String cacheDir) async { static Future<void> initExtensionStore(String cacheDir) async {
_log.d('initExtensionStore: $cacheDir'); _log.d('initExtensionStore: $cacheDir');
await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir}); await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir});
} }
/// Get all extensions from store with installation status
static Future<List<Map<String, dynamic>>> getStoreExtensions({bool forceRefresh = false}) async { static Future<List<Map<String, dynamic>>> getStoreExtensions({bool forceRefresh = false}) async {
_log.d('getStoreExtensions (forceRefresh: $forceRefresh)'); _log.d('getStoreExtensions (forceRefresh: $forceRefresh)');
final result = await _channel.invokeMethod('getStoreExtensions', { final result = await _channel.invokeMethod('getStoreExtensions', {
@@ -943,7 +827,6 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Search extensions in store
static Future<List<Map<String, dynamic>>> searchStoreExtensions(String query, {String? category}) async { static Future<List<Map<String, dynamic>>> searchStoreExtensions(String query, {String? category}) async {
_log.d('searchStoreExtensions: "$query" (category: $category)'); _log.d('searchStoreExtensions: "$query" (category: $category)');
final result = await _channel.invokeMethod('searchStoreExtensions', { final result = await _channel.invokeMethod('searchStoreExtensions', {
@@ -954,14 +837,12 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Get store categories
static Future<List<String>> getStoreCategories() async { static Future<List<String>> getStoreCategories() async {
final result = await _channel.invokeMethod('getStoreCategories'); final result = await _channel.invokeMethod('getStoreCategories');
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.cast<String>(); return list.cast<String>();
} }
/// Download extension from store
static Future<String> downloadStoreExtension(String extensionId, String destDir) async { static Future<String> downloadStoreExtension(String extensionId, String destDir) async {
_log.i('downloadStoreExtension: $extensionId to $destDir'); _log.i('downloadStoreExtension: $extensionId to $destDir');
final result = await _channel.invokeMethod('downloadStoreExtension', { final result = await _channel.invokeMethod('downloadStoreExtension', {
@@ -971,7 +852,6 @@ class PlatformBridge {
return result as String; return result as String;
} }
/// Clear store cache
static Future<void> clearStoreCache() async { static Future<void> clearStoreCache() async {
_log.d('clearStoreCache'); _log.d('clearStoreCache');
await _channel.invokeMethod('clearStoreCache'); await _channel.invokeMethod('clearStoreCache');
-10
View File
@@ -4,7 +4,6 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('ShareIntent'); final _log = AppLogger('ShareIntent');
/// Service to handle incoming share intents from other apps (e.g., Spotify)
class ShareIntentService { class ShareIntentService {
static final ShareIntentService _instance = ShareIntentService._internal(); static final ShareIntentService _instance = ShareIntentService._internal();
factory ShareIntentService() => _instance; factory ShareIntentService() => _instance;
@@ -15,17 +14,14 @@ class ShareIntentService {
bool _initialized = false; bool _initialized = false;
String? _pendingUrl; // Store URL received before listener is ready String? _pendingUrl; // Store URL received before listener is ready
/// Stream of shared Spotify URLs
Stream<String> get sharedUrlStream => _sharedUrlController.stream; Stream<String> get sharedUrlStream => _sharedUrlController.stream;
/// Get pending URL that was received before listener was ready
String? consumePendingUrl() { String? consumePendingUrl() {
final url = _pendingUrl; final url = _pendingUrl;
_pendingUrl = null; _pendingUrl = null;
return url; return url;
} }
/// Initialize the service and start listening for share intents
Future<void> initialize() async { Future<void> initialize() async {
if (_initialized) return; if (_initialized) return;
_initialized = true; _initialized = true;
@@ -58,11 +54,6 @@ class ShareIntentService {
} }
} }
/// Extract Spotify URL from shared text
/// Handles various formats:
/// - Direct URL: https://open.spotify.com/track/xxx
/// - With text: "Check out this song! https://open.spotify.com/track/xxx"
/// - Spotify URI: spotify:track:xxx
String? _extractSpotifyUrl(String text) { String? _extractSpotifyUrl(String text) {
if (text.isEmpty) return null; if (text.isEmpty) return null;
@@ -83,7 +74,6 @@ class ShareIntentService {
return null; return null;
} }
/// Dispose resources
void dispose() { void dispose() {
_mediaSubscription?.cancel(); _mediaSubscription?.cancel();
_sharedUrlController.close(); _sharedUrlController.close();
-11
View File
@@ -1,12 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotiflac_android/models/theme_settings.dart'; import 'package:spotiflac_android/models/theme_settings.dart';
/// App theme configuration for Material Expressive 3
class AppTheme { class AppTheme {
/// Default seed color (Spotify green) /// Default seed color (Spotify green)
static const Color defaultSeedColor = Color(kDefaultSeedColor); static const Color defaultSeedColor = Color(kDefaultSeedColor);
/// Create light theme
static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) { static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) {
final scheme = final scheme =
dynamicScheme ?? dynamicScheme ??
@@ -73,7 +71,6 @@ class AppTheme {
); );
} }
/// AppBar theme
static AppBarTheme _appBarTheme( static AppBarTheme _appBarTheme(
ColorScheme scheme, { ColorScheme scheme, {
bool isAmoled = false, bool isAmoled = false,
@@ -101,7 +98,6 @@ class AppTheme {
surfaceTintColor: scheme.surfaceTint, surfaceTintColor: scheme.surfaceTint,
); );
/// Elevated button theme
static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) => static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) =>
ElevatedButtonThemeData( ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -124,7 +120,6 @@ class AppTheme {
), ),
); );
/// Outlined button theme
static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) => static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) =>
OutlinedButtonThemeData( OutlinedButtonThemeData(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
@@ -146,7 +141,6 @@ class AppTheme {
), ),
); );
/// FAB theme
static FloatingActionButtonThemeData _fabTheme(ColorScheme scheme) => static FloatingActionButtonThemeData _fabTheme(ColorScheme scheme) =>
FloatingActionButtonThemeData( FloatingActionButtonThemeData(
elevation: 3, elevation: 3,
@@ -184,7 +178,6 @@ class AppTheme {
), // consistent padding ), // consistent padding
); );
/// List tile theme
static ListTileThemeData _listTileTheme(ColorScheme scheme) => static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
ListTileThemeData( ListTileThemeData(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -193,7 +186,6 @@ class AppTheme {
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
); );
/// Dialog theme
static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData( static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData(
elevation: 6, elevation: 6,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
@@ -213,7 +205,6 @@ class AppTheme {
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
); );
/// SnackBar theme
static SnackBarThemeData _snackBarTheme(ColorScheme scheme) => static SnackBarThemeData _snackBarTheme(ColorScheme scheme) =>
SnackBarThemeData( SnackBarThemeData(
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
@@ -231,7 +222,6 @@ class AppTheme {
circularTrackColor: scheme.surfaceContainerHighest, circularTrackColor: scheme.surfaceContainerHighest,
); );
/// Switch theme
static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData( static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) { thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) { if (states.contains(WidgetState.selected)) {
@@ -260,7 +250,6 @@ class AppTheme {
selectedColor: scheme.secondaryContainer, selectedColor: scheme.secondaryContainer,
); );
/// Divider theme
static DividerThemeData _dividerTheme(ColorScheme scheme) => static DividerThemeData _dividerTheme(ColorScheme scheme) =>
DividerThemeData(color: scheme.outlineVariant, thickness: 1, space: 1); DividerThemeData(color: scheme.outlineVariant, thickness: 1, space: 1);
} }
-7
View File
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
/// Log entry with timestamp and level
class LogEntry { class LogEntry {
final DateTime timestamp; final DateTime timestamp;
final String level; final String level;
@@ -38,7 +37,6 @@ class LogEntry {
} }
} }
/// Circular buffer for storing logs in memory
class LogBuffer extends ChangeNotifier { class LogBuffer extends ChangeNotifier {
static final LogBuffer _instance = LogBuffer._internal(); static final LogBuffer _instance = LogBuffer._internal();
factory LogBuffer() => _instance; factory LogBuffer() => _instance;
@@ -134,7 +132,6 @@ class LogBuffer extends ChangeNotifier {
_lastGoLogIndex = nextIndex; _lastGoLogIndex = nextIndex;
} catch (e) { } catch (e) {
// Ignore errors - Go backend might not be ready
if (kDebugMode) { if (kDebugMode) {
debugPrint('Failed to fetch Go logs: $e'); debugPrint('Failed to fetch Go logs: $e');
} }
@@ -180,7 +177,6 @@ class LogBuffer extends ChangeNotifier {
} }
} }
/// Custom log output that writes to both console and buffer
class BufferedOutput extends LogOutput { class BufferedOutput extends LogOutput {
final String tag; final String tag;
@@ -236,9 +232,6 @@ final log = Logger(
level: Level.debug, level: Level.debug,
); );
/// Logger with class/tag prefix for better traceability
/// Now also writes to LogBuffer for in-app viewing
/// Works in both debug and release mode
class AppLogger { class AppLogger {
final String _tag; final String _tag;
late final Logger? _logger; late final Logger? _logger;
-6
View File
@@ -2,10 +2,6 @@ import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
/// A wrapper around CachedNetworkImage that uses persistent cache storage.
///
/// This ensures cover images are cached to disk and persist across app restarts,
/// instead of being stored in the temporary directory that can be cleared by the OS.
class CachedCoverImage extends StatelessWidget { class CachedCoverImage extends StatelessWidget {
final String imageUrl; final String imageUrl;
final double? width; final double? width;
@@ -57,8 +53,6 @@ class CachedCoverImage extends StatelessWidget {
} }
} }
/// Provider for CachedNetworkImageProvider that uses persistent cache.
/// Use this for precacheImage() calls.
CachedNetworkImageProvider cachedCoverImageProvider(String url) { CachedNetworkImageProvider cachedCoverImageProvider(String url) {
return CachedNetworkImageProvider( return CachedNetworkImageProvider(
url, url,