v3.3.5: Same as 3.3.1 but fixes crash issues caused by FFmpeg

Changes:
- Fix FFmpeg crash issues during M4A to MP3/Opus conversion
- Add format picker (MP3/Opus) when selecting Tidal Lossy 320kbps
- Fix Deezer album blank screen when opened from home
- LRC file generation now follows lyrics mode setting
- Version bump to 3.3.5 (build 70)
This commit is contained in:
zarzet
2026-02-01 20:11:16 +07:00
parent eb0cdbeba8
commit 81b0eede8c
28 changed files with 236 additions and 456 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## [3.3.5] - 2026-02-01
Same as 3.3.1 but fixes crash issues caused by FFmpeg.
### Fixed
- **FFmpeg Crash**: Fixed crash issues during M4A to MP3/Opus conversion
---
## [3.3.1] - 2026-02-01
### Added
-3
View File
@@ -54,8 +54,6 @@ func NewAmazonDownloader() *AmazonDownloader {
return globalAmazonDownloader
}
// downloadFromAfkarXYZ downloads a track using AfkarXYZ API
// Returns: downloadURL, fileName, error
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
// AfkarXYZ API endpoint
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
@@ -206,7 +204,6 @@ type AmazonDownloadResult struct {
ISRC string
}
// downloadFromAmazon uses AfkarXYZ API to download from Amazon Music
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader()
-3
View File
@@ -425,7 +425,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
return result, nil
}
// GetTrack fetches a single track by Deezer ID
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
@@ -975,7 +974,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
return result, nil
}
// GetTrackAlbumID fetches the album ID for a Deezer track
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
@@ -1046,7 +1044,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
return json.Unmarshal(body, dst)
}
// parseDeezerURL is internal function, returns type and ID
func parseDeezerURL(input string) (string, string, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
+1 -18
View File
@@ -10,7 +10,6 @@ import (
"time"
)
// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking
type ISRCIndex struct {
index map[string]string // ISRC (uppercase) -> file path
outputDir string
@@ -25,8 +24,6 @@ var (
isrcIndexTTL = 5 * time.Minute
)
// GetISRCIndex returns or builds an ISRC index for the given directory
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
func GetISRCIndex(outputDir string) *ISRCIndex {
// Fast path: check cache first
isrcIndexCacheMu.RLock()
@@ -56,7 +53,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
return buildISRCIndex(outputDir)
}
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
func buildISRCIndex(outputDir string) *ISRCIndex {
idx := &ISRCIndex{
index: make(map[string]string),
@@ -91,7 +87,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return nil
})
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
isrcIndexCacheMu.Lock()
@@ -113,7 +109,6 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
return path, exists
}
// remove deletes an ISRC entry from the index (internal use)
func (idx *ISRCIndex) remove(isrc string) {
if isrc == "" {
return
@@ -125,14 +120,11 @@ func (idx *ISRCIndex) remove(isrc string) {
delete(idx.index, strings.ToUpper(isrc))
}
// Lookup checks if an ISRC exists in the index (gomobile compatible)
// Returns filepath if found, empty string if not found
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
path, _ := idx.lookup(isrc)
return path, nil
}
// Add adds a new ISRC to the index (call after successful download)
func (idx *ISRCIndex) Add(isrc, filePath string) {
if isrc == "" || filePath == "" {
return
@@ -144,15 +136,12 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
idx.index[strings.ToUpper(isrc)] = filePath
}
// InvalidateCache clears the ISRC index cache for a directory
func InvalidateISRCCache(outputDir string) {
isrcIndexCacheMu.Lock()
delete(isrcIndexCache, outputDir)
isrcIndexCacheMu.Unlock()
}
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
// Uses ISRC index for fast lookup
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
if isrc == "" || outputDir == "" {
return "", false
@@ -173,13 +162,11 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
return filePath, true
}
// CheckISRCExists is the exported version for gomobile (returns string, error)
func CheckISRCExists(outputDir, isrc string) (string, error) {
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
return filepath, nil
}
// CheckFileExists checks if a file with the given name exists
func CheckFileExists(filePath string) bool {
info, err := os.Stat(filePath)
if err != nil {
@@ -188,7 +175,6 @@ func CheckFileExists(filePath string) bool {
return !info.IsDir() && info.Size() > 0
}
// FileExistenceResult represents the result of checking if a file exists
type FileExistenceResult struct {
ISRC string `json:"isrc"`
Exists bool `json:"exists"`
@@ -249,8 +235,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
return string(resultJSON), nil
}
// PreBuildISRCIndex pre-builds the ISRC index for a directory
// Call this when app starts or when entering album/playlist screen
func PreBuildISRCIndex(outputDir string) error {
if outputDir == "" {
return fmt.Errorf("output directory is required")
@@ -260,7 +244,6 @@ func PreBuildISRCIndex(outputDir string) error {
return nil
}
// AddToISRCIndex adds a new file to the ISRC index after successful download
func AddToISRCIndex(outputDir, isrc, filePath string) {
if outputDir == "" || isrc == "" || filePath == "" {
return
+9 -68
View File
@@ -148,17 +148,16 @@ type DownloadRequest struct {
LyricsMode string `json:"lyrics_mode,omitempty"`
}
// DownloadResponse represents the result of a download
type DownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
ErrorType string `json:"error_type,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
Service string `json:"service,omitempty"` // Actual service used (for fallback)
Service string `json:"service,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
@@ -172,6 +171,7 @@ type DownloadResponse struct {
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"`
}
type DownloadResult struct {
@@ -185,6 +185,7 @@ type DownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
}
func DownloadTrack(requestJSON string) (string, error) {
@@ -222,6 +223,7 @@ func DownloadTrack(requestJSON string) (string, error) {
TrackNumber: tidalResult.TrackNumber,
DiscNumber: tidalResult.DiscNumber,
ISRC: tidalResult.ISRC,
LyricsLRC: tidalResult.LyricsLRC,
}
}
err = tidalErr
@@ -317,6 +319,7 @@ func DownloadTrack(requestJSON string) (string, error) {
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
LyricsLRC: result.LyricsLRC,
}
jsonBytes, _ := json.Marshal(resp)
@@ -380,6 +383,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: tidalResult.TrackNumber,
DiscNumber: tidalResult.DiscNumber,
ISRC: tidalResult.ISRC,
LyricsLRC: tidalResult.LyricsLRC,
}
} else if !errors.Is(tidalErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
@@ -452,6 +456,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
LyricsLRC: result.LyricsLRC,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
@@ -480,6 +485,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
LyricsLRC: result.LyricsLRC,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
@@ -631,14 +637,11 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
}
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
// If filePath is provided, ONLY check file - don't fallback to online
// This allows Flutter to distinguish between "from file" vs "from online"
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
return lyrics, nil
}
// File has no lyrics - return empty, let Flutter call again without filePath
return "", nil
}
@@ -649,7 +652,6 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
return "", err
}
// Return special marker for instrumental tracks
if lyricsData.Instrumental {
return "[instrumental:true]", nil
}
@@ -735,8 +737,6 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
}
// GetDeezerMetadata fetches metadata from Deezer URL or ID
// resourceType: track, album, artist, playlist
// resourceID: Deezer ID
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -770,7 +770,6 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
return string(jsonBytes), nil
}
// ParseDeezerURLExport parses a Deezer URL and returns type and ID
func ParseDeezerURLExport(url string) (string, error) {
resourceType, resourceID, err := parseDeezerURL(url)
if err != nil {
@@ -790,9 +789,6 @@ func ParseDeezerURLExport(url string) (string, error) {
return string(jsonBytes), nil
}
// GetDeezerExtendedMetadata fetches genre and label from Deezer album
// trackID: Deezer track ID (will look up album ID from track)
// Returns JSON with genre, label fields
func GetDeezerExtendedMetadata(trackID string) (string, error) {
if trackID == "" {
return "", fmt.Errorf("empty track ID")
@@ -821,7 +817,6 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
return string(jsonBytes), nil
}
// SearchDeezerByISRC searches for a track by ISRC on Deezer
func SearchDeezerByISRC(isrc string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -949,9 +944,6 @@ func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
}
// CheckAvailabilityByPlatformID checks track availability using any platform as source
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube"
// entityType: "song" or "album"
// entityID: the ID on that platform
func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID)
@@ -967,19 +959,16 @@ func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (strin
return string(jsonBytes), nil
}
// GetSpotifyIDFromDeezerTrack converts a Deezer track ID to Spotify track ID
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetSpotifyIDFromDeezer(deezerTrackID)
}
// GetTidalURLFromDeezerTrack converts a Deezer track ID to Tidal URL
func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetTidalURLFromDeezer(deezerTrackID)
}
// GetAmazonURLFromDeezerTrack converts a Deezer track ID to Amazon Music URL
func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetAmazonURLFromDeezer(deezerTrackID)
@@ -1029,7 +1018,6 @@ func errorResponse(msg string) (string, error) {
// ==================== EXTENSION SYSTEM ====================
// InitExtensionSystem initializes the extension system with directories
func InitExtensionSystem(extensionsDir, dataDir string) error {
manager := GetExtensionManager()
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
@@ -1044,7 +1032,6 @@ func InitExtensionSystem(extensionsDir, dataDir string) error {
return nil
}
// LoadExtensionsFromDir loads all extensions from a directory
func LoadExtensionsFromDir(dirPath string) (string, error) {
manager := GetExtensionManager()
loaded, errors := manager.LoadExtensionsFromDirectory(dirPath)
@@ -1066,7 +1053,6 @@ func LoadExtensionsFromDir(dirPath string) (string, error) {
return string(jsonBytes), nil
}
// LoadExtensionFromPath loads a single extension from a .spotiflac-ext file
func LoadExtensionFromPath(filePath string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.LoadExtensionFromFile(filePath)
@@ -1096,19 +1082,16 @@ func LoadExtensionFromPath(filePath string) (string, error) {
return string(jsonBytes), nil
}
// UnloadExtensionByID unloads an extension
func UnloadExtensionByID(extensionID string) error {
manager := GetExtensionManager()
return manager.UnloadExtension(extensionID)
}
// RemoveExtensionByID completely removes an extension (unload + delete files)
func RemoveExtensionByID(extensionID string) error {
manager := GetExtensionManager()
return manager.RemoveExtension(extensionID)
}
// UpgradeExtensionFromPath upgrades an existing extension from a new package file
func UpgradeExtensionFromPath(filePath string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.UpgradeExtension(filePath)
@@ -1137,25 +1120,21 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
return string(jsonBytes), nil
}
// CheckExtensionUpgradeFromPath checks if a package file is an upgrade for an existing extension
func CheckExtensionUpgradeFromPath(filePath string) (string, error) {
manager := GetExtensionManager()
return manager.CheckExtensionUpgradeJSON(filePath)
}
// GetInstalledExtensions returns all installed extensions as JSON
func GetInstalledExtensions() (string, error) {
manager := GetExtensionManager()
return manager.GetInstalledExtensionsJSON()
}
// SetExtensionEnabledByID enables or disables an extension
func SetExtensionEnabledByID(extensionID string, enabled bool) error {
manager := GetExtensionManager()
return manager.SetExtensionEnabled(extensionID, enabled)
}
// SetProviderPriorityJSON sets the provider priority order from JSON array
func SetProviderPriorityJSON(priorityJSON string) error {
var priority []string
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
@@ -1166,7 +1145,6 @@ func SetProviderPriorityJSON(priorityJSON string) error {
return nil
}
// GetProviderPriorityJSON returns the provider priority order as JSON
func GetProviderPriorityJSON() (string, error) {
priority := GetProviderPriority()
jsonBytes, err := json.Marshal(priority)
@@ -1176,7 +1154,6 @@ func GetProviderPriorityJSON() (string, error) {
return string(jsonBytes), nil
}
// SetMetadataProviderPriorityJSON sets the metadata provider priority order from JSON array
func SetMetadataProviderPriorityJSON(priorityJSON string) error {
var priority []string
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
@@ -1187,7 +1164,6 @@ func SetMetadataProviderPriorityJSON(priorityJSON string) error {
return nil
}
// GetMetadataProviderPriorityJSON returns the metadata provider priority order as JSON
func GetMetadataProviderPriorityJSON() (string, error) {
priority := GetMetadataProviderPriority()
jsonBytes, err := json.Marshal(priority)
@@ -1197,7 +1173,6 @@ func GetMetadataProviderPriorityJSON() (string, error) {
return string(jsonBytes), nil
}
// GetExtensionSettingsJSON returns settings for an extension as JSON
func GetExtensionSettingsJSON(extensionID string) (string, error) {
store := GetExtensionSettingsStore()
settings := store.GetAll(extensionID)
@@ -1210,7 +1185,6 @@ func GetExtensionSettingsJSON(extensionID string) (string, error) {
return string(jsonBytes), nil
}
// SetExtensionSettingsJSON sets settings for an extension from JSON
func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
var settings map[string]interface{}
if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil {
@@ -1226,7 +1200,6 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
return manager.InitializeExtension(extensionID, settings)
}
// SearchTracksWithExtensionsJSON searches all extension metadata providers
func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
manager := GetExtensionManager()
tracks, err := manager.SearchTracksWithExtensions(query, limit)
@@ -1242,7 +1215,6 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
return string(jsonBytes), nil
}
// DownloadWithExtensionsJSON downloads using extension providers with fallback
func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
@@ -1262,14 +1234,11 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// CleanupExtensions unloads all extensions gracefully
func CleanupExtensions() {
manager := GetExtensionManager()
manager.UnloadAllExtensions()
}
// InvokeExtensionActionJSON invokes a custom action on an extension (e.g., button click handler)
// actionName is the JS function name to call (e.g., "startLogin", "authenticate", etc.)
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
manager := GetExtensionManager()
result, err := manager.InvokeAction(extensionID, actionName)
@@ -1285,7 +1254,6 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
return string(jsonBytes), nil
}
// GetExtensionPendingAuthJSON returns pending auth request for an extension
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
req := GetPendingAuthRequest(extensionID)
if req == nil {
@@ -1306,12 +1274,10 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
return string(jsonBytes), nil
}
// SetExtensionAuthCodeByID sets auth code for an extension (called from Flutter after OAuth callback)
func SetExtensionAuthCodeByID(extensionID, authCode string) {
SetExtensionAuthCode(extensionID, authCode)
}
// SetExtensionTokensByID sets tokens for an extension
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
var expiresAt time.Time
if expiresIn > 0 {
@@ -1320,12 +1286,10 @@ func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expir
SetExtensionTokens(extensionID, accessToken, refreshToken, expiresAt)
}
// ClearExtensionPendingAuthByID clears pending auth request for an extension
func ClearExtensionPendingAuthByID(extensionID string) {
ClearPendingAuthRequest(extensionID)
}
// IsExtensionAuthenticatedByID checks if an extension is authenticated
func IsExtensionAuthenticatedByID(extensionID string) bool {
extensionAuthStateMu.RLock()
defer extensionAuthStateMu.RUnlock()
@@ -1342,7 +1306,6 @@ func IsExtensionAuthenticatedByID(extensionID string) bool {
return state.IsAuthenticated
}
// GetAllPendingAuthRequestsJSON returns all pending auth requests
func GetAllPendingAuthRequestsJSON() (string, error) {
pendingAuthRequestsMu.RLock()
defer pendingAuthRequestsMu.RUnlock()
@@ -1386,12 +1349,10 @@ func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
return string(jsonBytes), nil
}
// SetFFmpegCommandResultByID sets the result of an FFmpeg command
func SetFFmpegCommandResultByID(commandID string, success bool, output, errorMsg string) {
SetFFmpegCommandResult(commandID, success, output, errorMsg)
}
// GetAllPendingFFmpegCommandsJSON returns all pending FFmpeg commands
func GetAllPendingFFmpegCommandsJSON() (string, error) {
ffmpegCommandsMu.RLock()
defer ffmpegCommandsMu.RUnlock()
@@ -1417,8 +1378,6 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
// ==================== EXTENSION CUSTOM SEARCH ====================
// EnrichTrackWithExtensionJSON enriches track metadata using the source extension
// This is called lazily before download starts, allowing extension to fetch real ISRC etc.
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -1449,7 +1408,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
return string(jsonBytes), nil
}
// CustomSearchWithExtensionJSON performs custom search using an extension
func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -1502,7 +1460,6 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
return string(jsonBytes), nil
}
// GetSearchProvidersJSON returns all extensions that provide custom search
func GetSearchProvidersJSON() (string, error) {
manager := GetExtensionManager()
providers := manager.GetSearchProviders()
@@ -1666,8 +1623,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
return string(jsonBytes), nil
}
// FindURLHandlerJSON finds an extension that can handle the given URL
// Returns extension ID or empty string if none found
func FindURLHandlerJSON(url string) string {
manager := GetExtensionManager()
handler := manager.FindURLHandler(url)
@@ -1677,7 +1632,6 @@ func FindURLHandlerJSON(url string) string {
return handler.extension.ID
}
// GetAlbumWithExtensionJSON gets album tracks using an extension
func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -1752,7 +1706,6 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
return string(jsonBytes), nil
}
// GetPlaylistWithExtensionJSON gets playlist tracks using an extension
func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -1844,7 +1797,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
return string(jsonBytes), nil
}
// GetArtistWithExtensionJSON gets artist info and albums using an extension
func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -1928,7 +1880,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
return string(jsonBytes), nil
}
// GetURLHandlersJSON returns all extensions that handle custom URLs
func GetURLHandlersJSON() (string, error) {
manager := GetExtensionManager()
handlers := manager.GetURLHandlers()
@@ -1972,7 +1923,6 @@ func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
// GetPostProcessingProvidersJSON returns all extensions that provide post-processing
func GetPostProcessingProvidersJSON() (string, error) {
manager := GetExtensionManager()
providers := manager.GetPostProcessingProviders()
@@ -2005,13 +1955,11 @@ func GetPostProcessingProvidersJSON() (string, error) {
return string(jsonBytes), nil
}
// InitExtensionStoreJSON initializes the extension store with cache directory
func InitExtensionStoreJSON(cacheDir string) error {
InitExtensionStore(cacheDir)
return nil
}
// GetStoreExtensionsJSON returns all extensions from the store with installation status
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
store := GetExtensionStore()
if store == nil {
@@ -2035,7 +1983,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
return string(jsonBytes), nil
}
// SearchStoreExtensionsJSON searches extensions in the store
func SearchStoreExtensionsJSON(query, category string) (string, error) {
store := GetExtensionStore()
if store == nil {
@@ -2055,7 +2002,6 @@ func SearchStoreExtensionsJSON(query, category string) (string, error) {
return string(jsonBytes), nil
}
// GetStoreCategoriesJSON returns all available categories
func GetStoreCategoriesJSON() (string, error) {
store := GetExtensionStore()
if store == nil {
@@ -2071,8 +2017,6 @@ func GetStoreCategoriesJSON() (string, error) {
return string(jsonBytes), nil
}
// DownloadStoreExtensionJSON downloads an extension from the store
// Returns the path to the downloaded file
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
store := GetExtensionStore()
if store == nil {
@@ -2088,7 +2032,6 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
return destPath, nil
}
// ClearStoreCacheJSON clears the store cache
func ClearStoreCacheJSON() error {
store := GetExtensionStore()
if store == nil {
@@ -2139,12 +2082,10 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
return string(jsonBytes), nil
}
// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
}
// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
}
-7
View File
@@ -55,7 +55,6 @@ type LoadedExtension struct {
IconPath string `json:"icon_path"`
}
// ExtensionManager manages all loaded extensions
type ExtensionManager struct {
mu sync.RWMutex
extensions map[string]*LoadedExtension
@@ -283,7 +282,6 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
return nil
}
// UnloadExtension unloads an extension by ID
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -323,7 +321,6 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
return ext, nil
}
// GetAllExtensions returns all loaded extensions
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -356,7 +353,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
return nil
}
// LoadExtensionsFromDirectory scans a directory and loads all valid extensions
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
var loaded []string
var errors []error
@@ -456,7 +452,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return ext, nil
}
// RemoveExtension completely removes an extension (unload + delete files)
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
ext, err := m.GetExtension(extensionID)
if err != nil {
@@ -714,7 +709,6 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
return string(jsonBytes), nil
}
// GetInstalledExtensionsJSON returns all extensions as JSON for Flutter
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
extensions := m.GetAllExtensions()
@@ -923,7 +917,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
return nil
}
// UnloadAllExtensions unloads all extensions gracefully
func (m *ExtensionManager) UnloadAllExtensions() {
m.mu.Lock()
extensionIDs := make([]string, 0, len(m.extensions))
-26
View File
@@ -7,7 +7,6 @@ import (
"strings"
)
// ExtensionType represents the type of extension
type ExtensionType string
const (
@@ -15,7 +14,6 @@ const (
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
)
// SettingType represents the type of a setting field
type SettingType string
const (
@@ -26,14 +24,12 @@ const (
SettingTypeButton SettingType = "button" // Action button that calls a JS function
)
// ExtensionPermissions defines what resources an extension can access
type ExtensionPermissions struct {
Network []string `json:"network"` // List of allowed domains
Storage bool `json:"storage"` // Whether extension can use storage API
File bool `json:"file"` // Whether extension can use file API
}
// ExtensionSetting defines a configurable setting for an extension
type ExtensionSetting struct {
Key string `json:"key"`
Type SettingType `json:"type"`
@@ -46,7 +42,6 @@ type ExtensionSetting struct {
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin")
}
// QualityOption represents a quality option for download providers
type QualityOption struct {
ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128")
Label string `json:"label"` // Display name (e.g., "MP3 320kbps")
@@ -54,7 +49,6 @@ type QualityOption struct {
Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings
}
// QualitySpecificSetting represents a setting that's specific to a quality option
type QualitySpecificSetting struct {
Key string `json:"key"`
Type SettingType `json:"type"`
@@ -66,14 +60,12 @@ type QualitySpecificSetting struct {
Options []string `json:"options,omitempty"` // For select type
}
// SearchFilter defines a filter option for search
type SearchFilter struct {
ID string `json:"id"` // Filter identifier (e.g., "track", "album", "artist", "playlist")
Label string `json:"label,omitempty"` // Display label (e.g., "Songs", "Albums", "Artists", "Playlists")
Icon string `json:"icon,omitempty"` // Optional icon name
}
// SearchBehaviorConfig defines custom search behavior for an extension
type SearchBehaviorConfig struct {
Enabled bool `json:"enabled"` // Whether extension provides custom search
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box
@@ -85,20 +77,17 @@ type SearchBehaviorConfig struct {
Filters []SearchFilter `json:"filters,omitempty"` // Available search filters (e.g., track, album, artist, playlist)
}
// URLHandlerConfig defines custom URL handling for an extension
type URLHandlerConfig struct {
Enabled bool `json:"enabled"` // Whether extension handles URLs
Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com")
}
// TrackMatchingConfig defines custom track matching behavior
type TrackMatchingConfig struct {
CustomMatching bool `json:"customMatching"` // Whether extension handles matching
Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom"
DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching
}
// PostProcessingHook defines a post-processing hook
type PostProcessingHook struct {
ID string `json:"id"` // Unique identifier
Name string `json:"name"` // Display name
@@ -107,13 +96,11 @@ type PostProcessingHook struct {
SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"])
}
// PostProcessingConfig defines post-processing capabilities
type PostProcessingConfig struct {
Enabled bool `json:"enabled"` // Whether extension provides post-processing
Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks
}
// ExtensionManifest represents the manifest.json of an extension
type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
@@ -136,7 +123,6 @@ type ExtensionManifest struct {
Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.)
}
// ManifestValidationError represents a validation error in the manifest
type ManifestValidationError struct {
Field string
Message string
@@ -146,7 +132,6 @@ func (e *ManifestValidationError) Error() string {
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
}
// ParseManifest parses and validates a manifest from JSON bytes
func ParseManifest(data []byte) (*ExtensionManifest, error) {
var manifest ExtensionManifest
if err := json.Unmarshal(data, &manifest); err != nil {
@@ -225,7 +210,6 @@ func (m *ExtensionManifest) Validate() error {
return nil
}
// HasType checks if the extension has a specific type
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
for _, et := range m.Types {
if et == t {
@@ -235,17 +219,14 @@ func (m *ExtensionManifest) HasType(t ExtensionType) bool {
return false
}
// IsMetadataProvider returns true if extension provides metadata
func (m *ExtensionManifest) IsMetadataProvider() bool {
return m.HasType(ExtensionTypeMetadataProvider)
}
// IsDownloadProvider returns true if extension provides downloads
func (m *ExtensionManifest) IsDownloadProvider() bool {
return m.HasType(ExtensionTypeDownloadProvider)
}
// IsDomainAllowed checks if a domain is in the allowed network permissions
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
domain = strings.ToLower(strings.TrimSpace(domain))
for _, allowed := range m.Permissions.Network {
@@ -264,27 +245,22 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
return false
}
// HasCustomSearch returns true if extension provides custom search
func (m *ExtensionManifest) HasCustomSearch() bool {
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
}
// HasCustomMatching returns true if extension provides custom track matching
func (m *ExtensionManifest) HasCustomMatching() bool {
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
}
// HasPostProcessing returns true if extension provides post-processing
func (m *ExtensionManifest) HasPostProcessing() bool {
return m.PostProcessing != nil && m.PostProcessing.Enabled
}
// HasURLHandler returns true if extension handles custom URLs
func (m *ExtensionManifest) HasURLHandler() bool {
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
}
// MatchesURL checks if a URL matches any of the extension's URL patterns
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
if !m.HasURLHandler() {
return false
@@ -301,7 +277,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
return false
}
// GetPostProcessingHooks returns all post-processing hooks
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
if m.PostProcessing == nil {
return nil
@@ -309,7 +284,6 @@ func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
return m.PostProcessing.Hooks
}
// ToJSON serializes the manifest to JSON
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
return json.Marshal(m)
}
-2
View File
@@ -39,7 +39,6 @@ var (
pendingAuthRequestsMu sync.RWMutex
)
// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter)
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
pendingAuthRequestsMu.RLock()
defer pendingAuthRequestsMu.RUnlock()
@@ -201,7 +200,6 @@ func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
r.settings = settings
}
// RegisterAPIs registers all sandboxed APIs to the Goja VM
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
r.vm = vm
-4
View File
@@ -15,7 +15,6 @@ import (
// ==================== File API (Sandboxed) ====================
// List of allowed directories for file operations (set by Go backend for download operations)
var (
allowedDownloadDirs []string
allowedDownloadDirsMu sync.RWMutex
@@ -49,9 +48,6 @@ func isPathInAllowedDirs(absPath string) bool {
return false
}
// validatePath checks if the path is within the extension's sandbox
// Security: Absolute paths are BLOCKED unless they're in allowed download directories
// Extensions should use relative paths for their own data storage
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
// Check if extension has file permission
if !r.manifest.Permissions.File {
-7
View File
@@ -14,14 +14,12 @@ import (
// ==================== HTTP API (Sandboxed) ====================
// HTTPResponse represents the response from an HTTP request
type HTTPResponse struct {
StatusCode int `json:"statusCode"`
Body string `json:"body"`
Headers map[string]string `json:"headers"`
}
// validateDomain checks if the domain is allowed by the extension's permissions
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
parsed, err := url.Parse(urlStr)
if err != nil {
@@ -42,7 +40,6 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
return nil
}
// httpGet performs a GET request (sandboxed)
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -120,7 +117,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
})
}
// httpPost performs a POST request (sandboxed)
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -347,7 +343,6 @@ func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PUT", call)
}
// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE")
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("DELETE", call)
}
@@ -356,8 +351,6 @@ func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
return r.httpMethodShortcut("PATCH", call)
}
// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts
// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers)
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
-15
View File
@@ -9,7 +9,6 @@ import (
"sync"
)
// ExtensionSettingsStore manages settings for all extensions
type ExtensionSettingsStore struct {
mu sync.RWMutex
dataDir string
@@ -22,7 +21,6 @@ var (
globalSettingsStoreOnce sync.Once
)
// GetExtensionSettingsStore returns the global settings store
func GetExtensionSettingsStore() *ExtensionSettingsStore {
globalSettingsStoreOnce.Do(func() {
globalSettingsStore = &ExtensionSettingsStore{
@@ -32,7 +30,6 @@ func GetExtensionSettingsStore() *ExtensionSettingsStore {
return globalSettingsStore
}
// SetDataDir sets the data directory for settings storage
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -45,12 +42,10 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
return s.loadAllSettings()
}
// getSettingsPath returns the path to an extension's settings file
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
return filepath.Join(s.dataDir, extensionID, "settings.json")
}
// loadAllSettings loads settings for all extensions from disk
func (s *ExtensionSettingsStore) loadAllSettings() error {
entries, err := os.ReadDir(s.dataDir)
if err != nil {
@@ -75,7 +70,6 @@ func (s *ExtensionSettingsStore) loadAllSettings() error {
return nil
}
// loadSettings loads settings for a specific extension
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
settingsPath := s.getSettingsPath(extensionID)
data, err := os.ReadFile(settingsPath)
@@ -94,7 +88,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
return settings, nil
}
// saveSettings saves settings for a specific extension
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
settingsPath := s.getSettingsPath(extensionID)
@@ -111,8 +104,6 @@ func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[s
return os.WriteFile(settingsPath, data, 0644)
}
// Get retrieves a setting value for an extension
// Returns error if extension or key not found (gomobile compatible)
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -129,7 +120,6 @@ func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, erro
return value, nil
}
// GetAll retrieves all settings for an extension
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -147,7 +137,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
return result
}
// Set stores a setting value for an extension
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -161,7 +150,6 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
return s.saveSettings(extensionID, s.settings[extensionID])
}
// SetAll stores all settings for an extension
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -172,7 +160,6 @@ func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]
return s.saveSettings(extensionID, settings)
}
// Remove removes a setting for an extension
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -188,7 +175,6 @@ func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
return s.saveSettings(extensionID, extSettings)
}
// RemoveAll removes all settings for an extension
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -203,7 +189,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
return nil
}
// GetAllExtensionSettings returns settings for all extensions as JSON
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
+17 -35
View File
@@ -20,28 +20,26 @@ const (
CategoryIntegration = "integration"
)
// StoreExtension represents an extension in the store
type StoreExtension struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"`
Category string `json:"category"`
Tags []string `json:"tags,omitempty"`
Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"`
DisplayNameAlt string `json:"displayName,omitempty"`
DownloadURLAlt string `json:"downloadUrl,omitempty"`
IconURLAlt string `json:"iconUrl,omitempty"`
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Author string `json:"author"`
Description string `json:"description"`
DownloadURL string `json:"download_url,omitempty"`
IconURL string `json:"icon_url,omitempty"`
Category string `json:"category"`
Tags []string `json:"tags,omitempty"`
Downloads int `json:"downloads"`
UpdatedAt string `json:"updated_at"`
MinAppVersion string `json:"min_app_version,omitempty"`
DisplayNameAlt string `json:"displayName,omitempty"`
DownloadURLAlt string `json:"downloadUrl,omitempty"`
IconURLAlt string `json:"iconUrl,omitempty"`
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
}
// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict)
func (e *StoreExtension) getDisplayName() string {
if e.DisplayName != "" {
return e.DisplayName
@@ -52,7 +50,6 @@ func (e *StoreExtension) getDisplayName() string {
return e.Name
}
// getDownloadURL returns download URL from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getDownloadURL() string {
if e.DownloadURL != "" {
return e.DownloadURL
@@ -60,7 +57,6 @@ func (e *StoreExtension) getDownloadURL() string {
return e.DownloadURLAlt
}
// getIconURL returns icon URL from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getIconURL() string {
if e.IconURL != "" {
return e.IconURL
@@ -68,7 +64,6 @@ func (e *StoreExtension) getIconURL() string {
return e.IconURLAlt
}
// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict)
func (e *StoreExtension) getMinAppVersion() string {
if e.MinAppVersion != "" {
return e.MinAppVersion
@@ -76,7 +71,6 @@ func (e *StoreExtension) getMinAppVersion() string {
return e.MinAppVersionAlt
}
// StoreRegistry represents the extension registry
type StoreRegistry struct {
Version int `json:"version"`
UpdatedAt string `json:"updated_at"`
@@ -103,7 +97,6 @@ type StoreExtensionResponse struct {
HasUpdate bool `json:"has_update"`
}
// ToResponse converts StoreExtension to normalized response
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
return StoreExtensionResponse{
ID: e.ID,
@@ -122,7 +115,6 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
}
}
// ExtensionStore manages the extension store
type ExtensionStore struct {
registryURL string
cacheDir string
@@ -143,7 +135,6 @@ const (
cacheFileName = "store_cache.json"
)
// InitExtensionStore initializes the extension store
func InitExtensionStore(cacheDir string) *ExtensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
@@ -160,14 +151,12 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
return extensionStore
}
// GetExtensionStore returns the singleton store instance
func GetExtensionStore() *ExtensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
return extensionStore
}
// loadDiskCache loads cached registry from disk
func (s *ExtensionStore) loadDiskCache() {
if s.cacheDir == "" {
return
@@ -193,7 +182,6 @@ func (s *ExtensionStore) loadDiskCache() {
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
}
// saveDiskCache saves registry to disk cache
func (s *ExtensionStore) saveDiskCache() {
if s.cacheDir == "" || s.cache == nil {
return
@@ -216,7 +204,6 @@ func (s *ExtensionStore) saveDiskCache() {
os.WriteFile(cachePath, data, 0644)
}
// FetchRegistry fetches the extension registry from GitHub
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
@@ -267,7 +254,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return &registry, nil
}
// GetExtensionsWithStatus returns extensions with installation status
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
registry, err := s.FetchRegistry(false)
if err != nil {
@@ -299,7 +285,6 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
return result, nil
}
// DownloadExtension downloads an extension package to the specified path
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
registry, err := s.FetchRegistry(false)
if err != nil {
@@ -347,7 +332,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return nil
}
// GetCategories returns all available categories
func (s *ExtensionStore) GetCategories() []string {
return []string{
CategoryMetadata,
@@ -358,7 +342,6 @@ func (s *ExtensionStore) GetCategories() []string {
}
}
// SearchExtensions searches extensions by query
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
extensions, err := s.GetExtensionsWithStatus()
if err != nil {
@@ -404,7 +387,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
return result, nil
}
// ClearCache clears the in-memory and disk cache
func (s *ExtensionStore) ClearCache() {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
+1 -27
View File
@@ -15,9 +15,6 @@ import (
"time"
)
// getRandomUserAgent generates a random Windows Chrome User-Agent string
// Uses modern Chrome format with build and patch numbers
// Windows 11 still reports as "Windows NT 10.0" for compatibility
func getRandomUserAgent() string {
// Chrome version 120-145 (modern range)
chromeVersion := rand.Intn(26) + 120
@@ -38,10 +35,9 @@ const (
SongLinkTimeout = 30 * time.Second
DefaultMaxRetries = 3
DefaultRetryDelay = 1 * time.Second
Second = time.Second // Exported for use in other files
Second = time.Second
)
// Shared transport with connection pooling to prevent TCP exhaustion
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
@@ -85,7 +81,6 @@ func GetDownloadClient() *http.Client {
return downloadClient
}
// CloseIdleConnections closes idle connections in the shared transport
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
}
@@ -117,9 +112,6 @@ func DefaultRetryConfig() RetryConfig {
}
}
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
// Handles 429 (Too Many Requests) responses with Retry-After header
// Also detects and logs ISP blocking
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
var lastErr error
delay := config.InitialDelay
@@ -149,12 +141,10 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue
}
// Success
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return resp, nil
}
// Handle rate limiting (429)
if resp.StatusCode == 429 {
resp.Body.Close()
retryAfter := getRetryAfterDuration(resp)
@@ -194,7 +184,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
}
}
// Server errors (5xx) - retry
if resp.StatusCode >= 500 {
resp.Body.Close()
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
@@ -206,7 +195,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue
}
// Client errors (4xx except 429) - don't retry
return resp, nil
}
@@ -225,12 +213,10 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
return 60 * time.Second // Default wait time
}
// Try parsing as seconds
if seconds, err := strconv.Atoi(retryAfter); err == nil {
return time.Duration(seconds) * time.Second
}
// Try parsing as HTTP date
if t, err := http.ParseTime(retryAfter); err == nil {
duration := time.Until(t)
if duration > 0 {
@@ -241,8 +227,6 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
return 60 * time.Second // Default
}
// ReadResponseBody reads and returns the response body
// Returns error if body is empty
func ReadResponseBody(resp *http.Response) ([]byte, error) {
if resp == nil {
return nil, fmt.Errorf("response is nil")
@@ -272,14 +256,12 @@ func ValidateResponse(resp *http.Response) error {
return nil
}
// BuildErrorMessage creates a detailed error message for API failures
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
msg := fmt.Sprintf("API %s failed", apiURL)
if statusCode > 0 {
msg += fmt.Sprintf(" (HTTP %d)", statusCode)
}
if responsePreview != "" {
// Truncate preview if too long
if len(responsePreview) > 100 {
responsePreview = responsePreview[:100] + "..."
}
@@ -298,18 +280,14 @@ func (e *ISPBlockingError) Error() string {
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
}
// IsISPBlocking checks if an error is likely caused by ISP blocking
// Returns the ISPBlockingError if detected, nil otherwise
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
if err == nil {
return nil
}
// Extract domain from URL
domain := extractDomain(requestURL)
errStr := strings.ToLower(err.Error())
// Check for DNS resolution failure (common ISP blocking method)
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound || dnsErr.IsTemporary {
@@ -321,11 +299,9 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
}
}
// Check for connection refused (ISP firewall blocking)
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Op == "dial" {
// Check for specific syscall errors
var syscallErr syscall.Errno
if errors.As(opErr.Err, &syscallErr) {
switch syscallErr {
@@ -364,7 +340,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
}
}
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
var tlsErr *tls.RecordHeaderError
if errors.As(err, &tlsErr) {
return &ISPBlockingError{
@@ -425,7 +400,6 @@ func extractDomain(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
// Try to extract domain manually
rawURL = strings.TrimPrefix(rawURL, "https://")
rawURL = strings.TrimPrefix(rawURL, "http://")
if idx := strings.Index(rawURL, "/"); idx > 0 {
-39
View File
@@ -238,12 +238,9 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
return diff <= durationToleranceSec
}
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
// Normalize artist name - take first artist before comma/semicolon for better matching
primaryArtist := normalizeArtistName(artistName)
// Check cache first (use original artist name for cache key)
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
cachedCopy := *cached
@@ -254,12 +251,10 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
var lyrics *LyricsResponse
var err error
// Helper to check if lyrics result is valid (has lines OR is instrumental)
isValidResult := func(l *LyricsResponse) bool {
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
}
// Try exact match first with primary artist
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
if err == nil && isValidResult(lyrics) {
lyrics.Source = "LRCLIB"
@@ -267,7 +262,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
return lyrics, nil
}
// Try with full artist name if different from primary
if primaryArtist != artistName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && isValidResult(lyrics) {
@@ -277,7 +271,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
}
}
// Try with simplified track name
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
@@ -288,7 +281,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
}
}
// Search with duration matching (use primary artist for search)
query := primaryArtist + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && isValidResult(lyrics) {
@@ -297,7 +289,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
return lyrics, nil
}
// Search with simplified name and duration matching
if simplifiedTrack != trackName {
query = primaryArtist + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
@@ -393,32 +384,6 @@ func msToLRCTimestamp(ms int64) string {
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
}
// Use convertToLRCWithMetadata for full LRC with headers
// Kept for potential future use
// func convertToLRC(lyrics *LyricsResponse) string {
// if lyrics == nil || len(lyrics.Lines) == 0 {
// return ""
// }
//
// var builder strings.Builder
//
// if lyrics.SyncType == "LINE_SYNCED" {
// for _, line := range lyrics.Lines {
// timestamp := msToLRCTimestamp(line.StartTimeMs)
// builder.WriteString(timestamp)
// builder.WriteString(line.Words)
// builder.WriteString("\n")
// }
// } else {
// for _, line := range lyrics.Lines {
// builder.WriteString(line.Words)
// builder.WriteString("\n")
// }
// }
//
// return builder.String()
// }
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
if lyrics == nil || len(lyrics.Lines) == 0 {
return ""
@@ -480,11 +445,7 @@ func simplifyTrackName(name string) string {
return strings.TrimSpace(result)
}
// normalizeArtistName extracts the primary artist from multi-artist strings
// e.g., "HOYO-MiX, AURORA" -> "HOYO-MiX"
// e.g., "Artist1; Artist2" -> "Artist1"
func normalizeArtistName(name string) string {
// Split by common separators: ", " or "; " or " & " or " feat. " or " ft. "
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
result := name
-3
View File
@@ -238,7 +238,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
return f.Save(filePath)
}
// ReadMetadata reads metadata from a FLAC file
func ReadMetadata(filePath string) (*Metadata, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
@@ -336,7 +335,6 @@ func fileExists(path string) bool {
return err == nil
}
// ExtractCoverArt extracts cover art from a FLAC file
func ExtractCoverArt(filePath string) ([]byte, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
@@ -453,7 +451,6 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
return f.Save(filePath)
}
// ExtractLyrics extracts embedded lyrics from a FLAC file
func ExtractLyrics(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
+5 -21
View File
@@ -14,10 +14,9 @@ type TrackIDCacheEntry struct {
}
type TrackIDCache struct {
cache map[string]*TrackIDCacheEntry
mu sync.RWMutex
ttl time.Duration
// Cleanup is triggered on writes at a fixed interval to avoid unbounded growth.
cache map[string]*TrackIDCacheEntry
mu sync.RWMutex
ttl time.Duration
lastCleanup time.Time
cleanupInterval time.Duration
}
@@ -52,7 +51,6 @@ func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
return entry
}
// Lazily delete expired entry.
c.mu.Lock()
entry, exists = c.cache[isrc]
if exists && time.Now().After(entry.ExpiresAt) {
@@ -139,7 +137,6 @@ func (c *TrackIDCache) Size() int {
return len(c.cache)
}
// ParallelDownloadResult holds results from parallel operations
type ParallelDownloadResult struct {
CoverData []byte
LyricsData *LyricsResponse
@@ -164,14 +161,11 @@ func FetchCoverAndLyricsParallel(
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("[Parallel] Starting cover download...")
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
if err != nil {
result.CoverErr = err
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
} else {
result.CoverData = data
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
}
}()
}
@@ -180,20 +174,16 @@ func FetchCoverAndLyricsParallel(
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("[Parallel] Starting lyrics fetch...")
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
result.LyricsErr = err
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
} else if lyrics != nil && len(lyrics.Lines) > 0 {
result.LyricsData = lyrics
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else {
result.LyricsErr = fmt.Errorf("no lyrics found")
fmt.Println("[Parallel] No lyrics found")
}
}()
}
@@ -206,8 +196,8 @@ type PreWarmCacheRequest struct {
ISRC string
TrackName string
ArtistName string
SpotifyID string // Needed for Amazon (SongLink lookup)
Service string // "tidal", "qobuz", "amazon"
SpotifyID string
Service string
}
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
@@ -215,7 +205,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
return
}
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
cache := GetTrackIDCache()
semaphore := make(chan struct{}, 3)
@@ -244,7 +233,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
}
wg.Wait()
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
}
func preWarmTidalCache(isrc, _, _ string) {
@@ -252,7 +240,6 @@ func preWarmTidalCache(isrc, _, _ string) {
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
GetTrackIDCache().SetTidal(isrc, track.ID)
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
}
}
@@ -261,7 +248,6 @@ func preWarmQobuzCache(isrc string) {
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
GetTrackIDCache().SetQobuz(isrc, track.ID)
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
}
}
@@ -270,7 +256,6 @@ func preWarmAmazonCache(isrc, spotifyID string) {
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.Amazon {
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
}
}
@@ -283,7 +268,6 @@ func PreWarmCache(tracksJSON string) error {
func ClearTrackCache() {
GetTrackIDCache().Clear()
fmt.Println("[Cache] Track ID cache cleared")
}
func GetCacheSize() int {
-13
View File
@@ -78,7 +78,6 @@ func GetItemProgress(itemID string) string {
return "{}"
}
// StartItemProgress initializes progress tracking for an item
func StartItemProgress(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -93,7 +92,6 @@ func StartItemProgress(itemID string) {
}
}
// SetItemBytesTotal sets total bytes for an item
func SetItemBytesTotal(itemID string, total int64) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -103,7 +101,6 @@ func SetItemBytesTotal(itemID string, total int64) {
}
}
// SetItemBytesReceived sets bytes received for an item
func SetItemBytesReceived(itemID string, received int64) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -116,7 +113,6 @@ func SetItemBytesReceived(itemID string, received int64) {
}
}
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -130,7 +126,6 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
}
}
// CompleteItemProgress marks an item as complete
func CompleteItemProgress(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -142,7 +137,6 @@ func CompleteItemProgress(itemID string) {
}
}
// SetItemProgress sets progress for an item directly
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -158,7 +152,6 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
}
}
// SetItemFinalizing marks an item as finalizing (embedding metadata)
func SetItemFinalizing(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -169,7 +162,6 @@ func SetItemFinalizing(itemID string) {
}
}
// RemoveItemProgress removes progress tracking for an item
func RemoveItemProgress(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -177,7 +169,6 @@ func RemoveItemProgress(itemID string) {
delete(multiProgress.Items, itemID)
}
// ClearAllItemProgress clears all item progress
func ClearAllItemProgress() {
multiMu.Lock()
defer multiMu.Unlock()
@@ -185,7 +176,6 @@ func ClearAllItemProgress() {
multiProgress.Items = make(map[string]*ItemProgress)
}
// setDownloadDir sets the default download directory
func setDownloadDir(path string) error {
downloadDirMu.Lock()
defer downloadDirMu.Unlock()
@@ -193,7 +183,6 @@ func setDownloadDir(path string) error {
return nil
}
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) }
itemID string
@@ -206,7 +195,6 @@ type ItemProgressWriter struct {
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
// NewItemProgressWriter creates a new progress writer for a specific item
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
now := time.Now()
return &ItemProgressWriter{
@@ -220,7 +208,6 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
}
}
// Write implements io.Writer with threshold-based progress updates and speed tracking
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
return 0, ErrDownloadCancelled
-24
View File
@@ -112,8 +112,6 @@ func qobuzSplitArtists(artists string) []string {
return result
}
// qobuzSameWordsUnordered checks if two strings have the same words regardless of order
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
func qobuzSameWordsUnordered(a, b string) bool {
wordsA := strings.Fields(a)
wordsB := strings.Fields(b)
@@ -194,7 +192,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
return false
}
// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets
func qobuzExtractCoreTitle(title string) string {
// Find first occurrence of ( or [
parenIdx := strings.Index(title, "(")
@@ -281,9 +278,6 @@ func qobuzCleanTitle(title string) string {
return strings.TrimSpace(cleaned)
}
// qobuzIsLatinScript checks if a string is primarily Latin script
// Returns true for ASCII and Latin Extended characters (European languages)
// Returns false for CJK, Arabic, Cyrillic, etc.
func qobuzIsLatinScript(s string) bool {
for _, r := range s {
// Skip common punctuation and numbers
@@ -312,18 +306,6 @@ func qobuzIsLatinScript(s string) bool {
return true
}
// qobuzIsASCIIString checks if a string contains only ASCII characters
// Kept for potential future use
// func qobuzIsASCIIString(s string) bool {
// for _, r := range s {
// if r > 127 {
// return false
// }
// }
// return true
// }
// containsQueryQobuz checks if a query already exists in the list
func containsQueryQobuz(queries []string, query string) bool {
for _, q := range queries {
if q == query {
@@ -371,8 +353,6 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
return &track, nil
}
// GetAvailableAPIs returns list of available Qobuz APIs
// Uses same APIs as PC version for compatibility
func (q *QobuzDownloader) GetAvailableAPIs() []string {
// Same APIs as PC version (referensi/backend/qobuz.go)
// Primary: dab.yeet.su, Fallback: dabmusic.xyz, qobuz.squid.wtf
@@ -394,7 +374,6 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
return apis
}
// mapJumoQuality maps Qobuz quality codes to Jumo format
func mapJumoQuality(quality string) int {
switch quality {
case "6":
@@ -408,7 +387,6 @@ func mapJumoQuality(quality string) int {
}
}
// decodeXOR decodes XOR-encoded response from Jumo API
func decodeXOR(data []byte) string {
text := string(data)
runes := []rune(text)
@@ -420,7 +398,6 @@ func decodeXOR(data []byte) string {
return string(result)
}
// downloadFromJumo gets download URL from Jumo API (fallback)
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
formatID := mapJumoQuality(quality)
region := "US"
@@ -933,7 +910,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
}
// DownloadFile downloads a file from URL with User-Agent and progress tracking
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background()
-36
View File
@@ -43,33 +43,6 @@ func NewSongLinkClient() *SongLinkClient {
}
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
if spotifyTrackID == "" {
return nil, fmt.Errorf("spotify track ID is empty")
}
// Try SongLink first
availability, err := s.checkTrackAvailabilitySongLink(spotifyTrackID)
if err != nil {
// Fallback to IDHS if SongLink fails
LogWarn("SongLink", "SongLink failed, trying IDHS fallback: %v", err)
idhsClient := NewIDHSClient()
availability, err = idhsClient.GetAvailabilityFromSpotify(spotifyTrackID)
if err != nil {
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
}
LogInfo("SongLink", "IDHS fallback successful for %s", spotifyTrackID)
}
// Check Qobuz availability separately via ISRC
if isrc != "" {
availability.Qobuz = checkQobuzAvailability(isrc)
}
return availability, nil
}
// checkTrackAvailabilitySongLink is the original SongLink implementation
func (s *SongLinkClient) checkTrackAvailabilitySongLink(spotifyTrackID string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
@@ -227,10 +200,8 @@ type AlbumAvailability struct {
}
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
// Use global rate limiter
songLinkRateLimiter.WaitForSlot()
// Build API URL for album
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
@@ -301,10 +272,8 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
return nil, fmt.Errorf("deezer track ID is empty")
}
// Try SongLink first
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
if err != nil {
// Fallback to IDHS if SongLink fails
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
idhsClient := NewIDHSClient()
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
@@ -338,7 +307,6 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
}
defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
}
@@ -407,11 +375,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
return nil, fmt.Errorf("%s ID is empty", platform)
}
// Use global rate limiter
songLinkRateLimiter.WaitForSlot()
// Build API URL using platform, type, and id parameters (as per API docs)
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
url.QueryEscape(platform),
url.QueryEscape(entityType),
@@ -429,7 +394,6 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
}
defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
}
-2
View File
@@ -66,7 +66,6 @@ var (
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
// SetSpotifyCredentials sets custom Spotify API credentials
func SetSpotifyCredentials(clientID, clientSecret string) {
credentialsMu.Lock()
defer credentialsMu.Unlock()
@@ -89,7 +88,6 @@ func HasSpotifyCredentials() bool {
return false
}
// getCredentials returns the current credentials or error if not configured
func getCredentials() (string, string, error) {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
+29 -9
View File
@@ -119,7 +119,6 @@ func NewTidalDownloader() *TidalDownloader {
return globalTidalDownloader
}
// GetAvailableAPIs returns list of available Tidal APIs
func (t *TidalDownloader) GetAvailableAPIs() []string {
encodedAPIs := []string{
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority)
@@ -251,7 +250,6 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
return trackID, nil
}
// GetTrackInfoByID gets track info by Tidal track ID
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
token, err := t.GetAccessToken()
if err != nil {
@@ -797,7 +795,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
return "", initURL, mediaURLs, nil
}
// DownloadFile downloads a file from URL with progress tracking
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
ctx := context.Background()
@@ -1104,6 +1101,7 @@ type TidalDownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string // LRC content for embedding in converted files
}
func artistsMatch(spotifyArtist, tidalArtist string) bool {
@@ -1683,14 +1681,24 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
if quality == "HIGH" {
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
// Only save external LRC file for lyrics
// Handle lyrics based on lyricsMode setting
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
GoLog("[Tidal] Saving external LRC file for M4A...\n")
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed" // default
}
// Save external LRC file if mode is "external" or "both"
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
}
}
// Note: For "embed" or "both" modes, LyricsLRC will be returned to Flutter
// for embedding into the converted MP3/Opus file
}
} else {
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
@@ -1702,10 +1710,21 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
// For HIGH quality (AAC), set appropriate values
bitDepth := downloadInfo.BitDepth
sampleRate := downloadInfo.SampleRate
lyricsLRC := ""
if quality == "HIGH" {
// AAC 320kbps doesn't have traditional bit depth
bitDepth = 0
sampleRate = 44100
// Return lyrics for Flutter to embed in converted MP3/Opus
if parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "embed" || lyricsMode == "both" {
lyricsLRC = parallelResult.LyricsLRC
}
}
}
return TidalDownloadResult{
@@ -1719,5 +1738,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
TrackNumber: actualTrackNumber,
DiscNumber: actualDiscNumber,
ISRC: track.ISRC,
LyricsLRC: lyricsLRC,
}, nil
}
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.3.1';
static const String buildNumber = '68';
static const String version = '3.3.5';
static const String buildNumber = '70';
static const String fullVersion = '$version+$buildNumber';
+47 -26
View File
@@ -150,15 +150,12 @@ class DownloadHistoryState {
.map((item) => MapEntry(item.isrc!, item)),
);
/// O(1) check if spotify_id exists
bool isDownloaded(String spotifyId) =>
_downloadedSpotifyIds.contains(spotifyId);
/// O(1) lookup by spotify_id
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
_bySpotifyId[spotifyId];
/// O(1) lookup by ISRC
DownloadHistoryItem? getByIsrc(String isrc) =>
_byIsrc[isrc];
@@ -177,7 +174,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return DownloadHistoryState();
}
/// Synchronously schedule load - ensures it runs before any UI renders
void _loadFromDatabaseSync() {
if (_isLoaded) return;
_isLoaded = true;
@@ -193,7 +189,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
_historyLog.i('Migrated history from SharedPreferences to SQLite');
}
// Migrate iOS paths if container UUID changed after app update
if (Platform.isIOS) {
final pathsMigrated = await _db.migrateIosContainerPaths();
if (pathsMigrated) {
@@ -264,12 +259,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return state.getBySpotifyId(spotifyId);
}
/// O(1) lookup by ISRC
DownloadHistoryItem? getByIsrc(String isrc) {
return state.getByIsrc(isrc);
}
/// Async version with database lookup (for cases where in-memory might be stale)
Future<DownloadHistoryItem?> getBySpotifyIdAsync(String spotifyId) async {
final inMemory = state.getBySpotifyId(spotifyId);
if (inMemory != null) return inMemory;
@@ -286,7 +279,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
});
}
/// Get database stats for debugging
Future<int> getDatabaseCount() async {
return await _db.getCount();
}
@@ -722,7 +714,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final isSingle = track.isSingle;
final artistName = _sanitizeFolderName(albumArtist);
// New option: Singles folder inside Artist folder
if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) {
final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles';
@@ -736,7 +727,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
// Existing behavior: Separate Albums/ and Singles/ at root
if (isSingle) {
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
await _ensureDirExists(singlesPath, label: 'Singles folder');
@@ -804,7 +794,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.trim();
}
/// Extract year from release date (format: "2005-06-13" or "2005")
String? _extractYear(String? releaseDate) {
if (releaseDate == null || releaseDate.isEmpty) return null;
final match = _yearRegex.firstMatch(releaseDate);
@@ -1075,7 +1064,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
/// Same logic as Go backend cover.go
String _upgradeToMaxQualityCover(String coverUrl) {
const spotifySize300 = 'ab67616d00001e02';
const spotifySize640 = 'ab67616d0000b273';
@@ -1192,7 +1180,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
durationMs: durationMs,
);
// Skip instrumental tracks (no lyrics to embed)
if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent;
@@ -1323,7 +1310,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('MP3 Metadata map content: $metadata');
if (settings.embedLyrics) {
final lyricsMode = settings.lyricsMode;
final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both';
final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both';
if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) {
try {
final durationMs = track.duration * 1000;
@@ -1336,12 +1327,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
if (lrcContent.isNotEmpty) {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)');
if (shouldEmbed) {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)');
}
if (shouldSaveExternal) {
try {
final lrcPath = mp3Path.replaceAll(RegExp(r'\.mp3$', caseSensitive: false), '.lrc');
await File(lrcPath).writeAsString(lrcContent);
_log.d('External LRC file saved: $lrcPath');
} catch (e) {
_log.w('Failed to save external LRC file: $e');
}
}
}
} catch (e) {
_log.w('Failed to fetch lyrics for MP3 embedding: $e');
_log.w('Failed to fetch lyrics for MP3: $e');
}
}
@@ -1461,7 +1464,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Opus Metadata map content: $metadata');
if (settings.embedLyrics) {
// Handle lyrics based on lyricsMode setting
final lyricsMode = settings.lyricsMode;
final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both';
final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both';
if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) {
try {
final durationMs = track.duration * 1000;
@@ -1474,11 +1482,25 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
if (lrcContent.isNotEmpty) {
metadata['LYRICS'] = lrcContent;
_log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)');
// Embed lyrics in file metadata if mode is 'embed' or 'both'
if (shouldEmbed) {
metadata['LYRICS'] = lrcContent;
_log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)');
}
// Save external LRC file if mode is 'external' or 'both'
if (shouldSaveExternal) {
try {
final lrcPath = opusPath.replaceAll(RegExp(r'\.opus$', caseSensitive: false), '.lrc');
await File(lrcPath).writeAsString(lrcContent);
_log.d('External LRC file saved: $lrcPath');
} catch (e) {
_log.w('Failed to save external LRC file: $e');
}
}
}
} catch (e) {
_log.w('Failed to fetch lyrics for Opus embedding: $e');
_log.w('Failed to fetch lyrics for Opus: $e');
}
}
@@ -1805,7 +1827,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final quality = item.qualityOverride ?? state.audioQuality;
// Fetch extended metadata (genre, label) from Deezer if available
String? genre;
String? label;
@@ -2148,7 +2169,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} catch (e) {
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
}
} // end else (not HIGH quality)
}
}
final itemAfterDownload = state.items.firstWhere(
+7 -2
View File
@@ -92,10 +92,15 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
});
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
// Use provided tracks if not empty, otherwise try cache
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
_tracks = widget.tracks;
} else {
_tracks = _AlbumCache.get(widget.albumId);
}
_artistId = widget.artistId; // Use provided artist ID if available
if (_tracks == null) {
if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks();
}
+3 -52
View File
@@ -9,8 +9,6 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg');
/// FFmpeg service for audio conversion and remuxing
/// Uses ffmpeg_kit_flutter_new_audio plugin
class FFmpegService {
static Future<FFmpegResult> _execute(String command) async {
try {
@@ -48,16 +46,12 @@ class FFmpegService {
return null;
}
/// Convert M4A (AAC) to lossy format (MP3 or Opus)
/// format: 'mp3' or 'opus'
/// bitrate: e.g., 'mp3_320', 'opus_128' - extracts the kbps value
static Future<String?> convertM4aToLossy(
String inputPath, {
required String format,
String? bitrate,
bool deleteOriginal = true,
}) async {
// Extract bitrate value from format like 'mp3_320' -> '320k'
String bitrateValue = format == 'opus' ? '128k' : '320k';
if (bitrate != null && bitrate.contains('_')) {
final parts = bitrate.split('_');
@@ -71,11 +65,9 @@ class FFmpegService {
String command;
if (format == 'opus') {
// M4A -> Opus conversion
command =
'-i "$inputPath" -codec:a libopus -b:a $bitrateValue -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
} else {
// M4A -> MP3 conversion
command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrateValue -map 0:a -id3v2_version 3 "$outputPath" -y';
}
@@ -127,7 +119,6 @@ class FFmpegService {
}) async {
final outputPath = inputPath.replaceAll('.flac', '.opus');
// Opus in OGG container with VBR
final command =
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a -map_metadata 0 "$outputPath" -y';
@@ -146,17 +137,13 @@ class FFmpegService {
return null;
}
/// Convert FLAC to lossy format based on format parameter
/// format: 'mp3' or 'opus'
/// bitrate: e.g., 'mp3_320', 'opus_128' - extracts the kbps value
static Future<String?> convertFlacToLossy(
String inputPath, {
required String format,
String? bitrate,
bool deleteOriginal = true,
}) async {
// Extract bitrate value from format like 'mp3_320' -> '320k'
String bitrateValue = '320k'; // default for mp3
String bitrateValue = '320k';
if (bitrate != null && bitrate.contains('_')) {
final parts = bitrate.split('_');
if (parts.length == 2) {
@@ -385,8 +372,6 @@ class FFmpegService {
return null;
}
/// Embed metadata to Opus file
/// Uses METADATA_BLOCK_PICTURE tag for cover art (OGG/Vorbis standard)
static Future<String?> embedMetadataToOpus({
required String opusPath,
String? coverPath,
@@ -401,7 +386,6 @@ class FFmpegService {
cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-c:a copy ');
// Embed metadata tags (Vorbis comments)
if (metadata != null) {
metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
@@ -409,12 +393,10 @@ class FFmpegService {
});
}
// Embed cover art using METADATA_BLOCK_PICTURE
if (coverPath != null) {
try {
final pictureBlock = await _createMetadataBlockPicture(coverPath);
if (pictureBlock != null) {
// Escape special characters for shell
final escapedBlock = pictureBlock.replaceAll('"', '\\"');
cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" ');
_log.d('Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)');
@@ -471,19 +453,6 @@ class FFmpegService {
return null;
}
/// Create METADATA_BLOCK_PICTURE base64 string for OGG/Opus cover art
/// Format follows FLAC picture block specification:
/// - 4 bytes: picture type (3 = front cover)
/// - 4 bytes: MIME type length
/// - n bytes: MIME type string
/// - 4 bytes: description length
/// - n bytes: description string
/// - 4 bytes: width
/// - 4 bytes: height
/// - 4 bytes: color depth
/// - 4 bytes: colors used (0 for non-indexed)
/// - 4 bytes: picture data length
/// - n bytes: picture data
static Future<String?> _createMetadataBlockPicture(String imagePath) async {
try {
final file = File(imagePath);
@@ -494,7 +463,6 @@ class FFmpegService {
final imageData = await file.readAsBytes();
// Detect MIME type from file extension or magic bytes
String mimeType;
if (imagePath.toLowerCase().endsWith('.png')) {
mimeType = 'image/png';
@@ -502,7 +470,6 @@ class FFmpegService {
imagePath.toLowerCase().endsWith('.jpeg')) {
mimeType = 'image/jpeg';
} else {
// Check magic bytes
if (imageData.length >= 8 &&
imageData[0] == 0x89 && imageData[1] == 0x50 &&
imageData[2] == 0x4E && imageData[3] == 0x47) {
@@ -511,75 +478,61 @@ class FFmpegService {
imageData[0] == 0xFF && imageData[1] == 0xD8) {
mimeType = 'image/jpeg';
} else {
mimeType = 'image/jpeg'; // Default to JPEG
mimeType = 'image/jpeg';
}
}
final mimeBytes = utf8.encode(mimeType);
const description = ''; // Empty description
const description = '';
final descBytes = utf8.encode(description);
// Build the FLAC picture block
// Total size: 4 + 4 + mimeLen + 4 + descLen + 4 + 4 + 4 + 4 + 4 + imageLen
final blockSize = 4 + 4 + mimeBytes.length + 4 + descBytes.length +
4 + 4 + 4 + 4 + 4 + imageData.length;
final buffer = ByteData(blockSize);
var offset = 0;
// Picture type: 3 = Front cover
buffer.setUint32(offset, 3, Endian.big);
offset += 4;
// MIME type length
buffer.setUint32(offset, mimeBytes.length, Endian.big);
offset += 4;
// MIME type string
final blockBytes = Uint8List(blockSize);
blockBytes.setRange(0, offset, buffer.buffer.asUint8List());
blockBytes.setRange(offset, offset + mimeBytes.length, mimeBytes);
offset += mimeBytes.length;
// Description length
final tempBuffer = ByteData(4);
tempBuffer.setUint32(0, descBytes.length, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
// Description string
blockBytes.setRange(offset, offset + descBytes.length, descBytes);
offset += descBytes.length;
// Width (0 = unknown)
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
// Height (0 = unknown)
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
// Color depth (0 = unknown)
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
// Colors used (0 for non-indexed)
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
// Picture data length
tempBuffer.setUint32(0, imageData.length, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
// Picture data
blockBytes.setRange(offset, offset + imageData.length, imageData);
// Base64 encode the entire block
final base64String = base64Encode(blockBytes);
return base64String;
@@ -596,7 +549,6 @@ class FFmpegService {
final key = entry.key.toUpperCase();
final value = entry.value;
// Map Vorbis comments to ID3v2 frame names
switch (key) {
case 'TITLE':
id3Map['title'] = value;
@@ -630,7 +582,6 @@ class FFmpegService {
id3Map['lyrics'] = value;
break;
default:
// Pass through other tags as-is
id3Map[key.toLowerCase()] = value;
}
}
+1 -9
View File
@@ -10,7 +10,7 @@ class LogEntry {
final String tag;
final String message;
final String? error;
final bool isFromGo; // Track if this log came from Go backend
final bool isFromGo;
LogEntry({
required this.timestamp,
@@ -47,8 +47,6 @@ class LogBuffer extends ChangeNotifier {
Timer? _goLogTimer;
int _lastGoLogIndex = 0;
/// Whether logging is enabled (controlled by settings)
/// User must enable "Detailed Logging" in settings to capture logs
static bool _loggingEnabled = false;
static bool get loggingEnabled => _loggingEnabled;
static set loggingEnabled(bool value) {
@@ -64,7 +62,6 @@ class LogBuffer extends ChangeNotifier {
int get length => _entries.length;
void add(LogEntry entry) {
// Skip adding if logging is disabled (except for errors which are always logged)
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
return;
}
@@ -76,7 +73,6 @@ class LogBuffer extends ChangeNotifier {
notifyListeners();
}
/// Start polling Go backend logs
void startGoLogPolling() {
_goLogTimer?.cancel();
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
@@ -84,13 +80,11 @@ class LogBuffer extends ChangeNotifier {
});
}
/// Stop polling Go backend logs
void stopGoLogPolling() {
_goLogTimer?.cancel();
_goLogTimer = null;
}
/// Fetch logs from Go backend since last index
Future<void> _fetchGoLogs() async {
try {
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
@@ -103,7 +97,6 @@ class LogBuffer extends ChangeNotifier {
final tag = log['tag'] as String? ?? 'Go';
final message = log['message'] as String? ?? '';
// Parse timestamp (format: "15:04:05.000")
DateTime parsedTime = DateTime.now();
if (timestamp.isNotEmpty) {
try {
@@ -221,7 +214,6 @@ class BufferedOutput extends LogOutput {
}
}
/// Global logger instance for the app
final log = Logger(
printer: PrettyPrinter(
methodCount: 0,
+103 -2
View File
@@ -218,8 +218,13 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
subtitle: quality.description ?? '',
icon: _getQualityIcon(quality.id),
onTap: () {
Navigator.pop(context);
widget.onSelect(quality.id, _selectedService);
// For Tidal HIGH quality, show format picker first
if (_selectedService == 'tidal' && quality.id == 'HIGH') {
_showLossyFormatPicker(context);
} else {
Navigator.pop(context);
widget.onSelect(quality.id, _selectedService);
}
},
),
@@ -250,6 +255,102 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
return Icons.music_note;
}
}
void _showLossyFormatPicker(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
final currentFormat = settings.tidalHighFormat;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (modalContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
'Select Lossy Format',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose output format for 320kbps lossy download',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.audiotrack, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('MP3 320kbps'),
subtitle: const Text('Best compatibility, ~10MB per track'),
trailing: currentFormat == 'mp3_320'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
Navigator.pop(modalContext); // Close format picker
Navigator.pop(context); // Close service picker
widget.onSelect('HIGH', _selectedService);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('Opus 128kbps'),
subtitle: const Text('Modern codec, ~4MB per track'),
trailing: currentFormat == 'opus_128'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
Navigator.pop(modalContext); // Close format picker
Navigator.pop(context); // Close service picker
widget.onSelect('HIGH', _selectedService);
},
),
const SizedBox(height: 16),
],
),
),
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.3.1+68
version: 3.3.5+70
environment:
sdk: ^3.10.0