refactor: remove more redundant comments

This commit is contained in:
zarzet
2026-02-04 10:20:04 +07:00
parent 24897e25e2
commit e20becdca7
16 changed files with 44 additions and 315 deletions
+6 -34
View File
@@ -180,7 +180,6 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
}
}
// parseID3v23Frames parses ID3v2.3 and ID3v2.4 frames (4-char frame IDs)
func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUnsync bool) {
pos := 0
for pos+10 < len(data) {
@@ -191,10 +190,8 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
var frameSize int
if version == 4 {
// ID3v2.4 uses syncsafe integers
frameSize = int(data[pos+4])<<21 | int(data[pos+5])<<14 | int(data[pos+6])<<7 | int(data[pos+7])
} else {
// ID3v2.3 uses regular integers
frameSize = int(data[pos+4])<<24 | int(data[pos+5])<<16 | int(data[pos+6])<<8 | int(data[pos+7])
}
@@ -208,9 +205,7 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
_ = statusFlags
formatFlags := data[pos+9]
// Handle frame-specific flags
if version == 3 {
// ID3v2.3 format flags: compression/encryption/grouping not supported
const (
id3v23FlagCompression = 0x80
id3v23FlagEncryption = 0x40
@@ -231,7 +226,6 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
frameData = removeUnsync(frameData)
}
} else if version == 4 {
// ID3v2.4 format flags: grouping, compression, encryption, unsync, data length indicator
const (
id3v24FlagGrouping = 0x40
id3v24FlagCompression = 0x08
@@ -527,27 +521,24 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
sampleRates := [][]int{
{11025, 12000, 8000}, // MPEG 2.5
{0, 0, 0}, // Reserved
{22050, 24000, 16000}, // MPEG 2
{44100, 48000, 32000}, // MPEG 1
{11025, 12000, 8000},
{0, 0, 0},
{22050, 24000, 16000},
{44100, 48000, 32000},
}
if version < 4 && sampleRateIdx < 3 {
quality.SampleRate = sampleRates[version][sampleRateIdx]
}
// Get bitrate (for MPEG 1 Layer 3)
if version == 3 && layer == 1 { // MPEG 1, Layer 3
if version == 3 && layer == 1 {
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
// MP3 is always 16-bit PCM when decoded
quality.BitDepth = 16
// Estimate duration from file size and bitrate
if quality.Bitrate > 0 {
audioSize := fileSize - audioStart - 128
if audioSize > 0 {
@@ -564,11 +555,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
return quality, nil
}
// =============================================================================
// Ogg/Opus Vorbis Comment Reading
// =============================================================================
// ReadOggVorbisComments reads Vorbis comments from Ogg/Opus files
func ReadOggVorbisComments(filePath string) (*AudioMetadata, error) {
file, err := os.Open(filePath)
if err != nil {
@@ -598,7 +584,6 @@ func ReadOggVorbisComments(filePath string) (*AudioMetadata, error) {
break
}
}
// Fallback: if unknown, still try OpusTags
if streamType == oggStreamUnknown {
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
parseVorbisComments(pkt[8:], metadata)
@@ -620,7 +605,6 @@ type oggPage struct {
data []byte
}
// readOggPageWithHeader reads a single Ogg page including header info
func readOggPageWithHeader(file *os.File) (*oggPage, error) {
header := make([]byte, 27)
if _, err := io.ReadFull(file, header); err != nil {
@@ -656,7 +640,6 @@ func readOggPageWithHeader(file *os.File) (*oggPage, error) {
}, nil
}
// readOggPage reads a single Ogg page (data only)
func readOggPage(file *os.File) ([]byte, error) {
page, err := readOggPageWithHeader(file)
if err != nil {
@@ -665,7 +648,6 @@ func readOggPage(file *os.File) ([]byte, error) {
return page.data, nil
}
// collectOggPackets reads Ogg pages and returns reassembled packets
func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error) {
const maxPacketSize = 10 * 1024 * 1024
var packets [][]byte
@@ -681,7 +663,6 @@ func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error
return nil, err
}
// If this page is not a continuation but we have partial packet, drop it
if page.headerType&0x01 == 0 && len(cur) > 0 {
cur = nil
skipPacket = false
@@ -1197,7 +1178,7 @@ func parseFLACPictureBlock(data []byte) ([]byte, string) {
var dataLen uint32
binary.Read(reader, binary.BigEndian, &dataLen)
if dataLen > 10000000 { // 10MB
if dataLen > 10000000 {
return nil, ""
}
@@ -1207,12 +1188,10 @@ func parseFLACPictureBlock(data []byte) ([]byte, string) {
return imageData, mimeType
}
// base64StdDecodeLen returns decoded length
func base64StdDecodeLen(n int) int {
return n * 6 / 8
}
// base64StdDecode decodes base64 data (simplified)
func base64StdDecode(dst, src []byte) (int, error) {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
@@ -1270,7 +1249,6 @@ func base64StdDecode(dst, src []byte) (int, error) {
return di, nil
}
// extractAnyCoverArt extracts cover art from any supported audio file
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
@@ -1280,7 +1258,6 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
if err != nil {
return nil, "", err
}
// Detect MIME type from magic bytes
mimeType := "image/jpeg"
if len(data) > 8 && string(data[1:4]) == "PNG" {
mimeType = "image/png"
@@ -1294,8 +1271,6 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
return extractOggCoverArt(filePath)
case ".m4a":
// M4A cover extraction would need more complex MP4 atom parsing
// For now, return error
return nil, "", fmt.Errorf("M4A cover extraction not yet supported")
default:
@@ -1303,10 +1278,7 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
}
}
// SaveCoverToCache extracts and saves cover art to cache directory
// Returns the path to the saved cover image, or empty string if no cover found
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
// Generate cache filename from file path + size + mtime to reduce stale cache
cacheKey := filePath
if stat, err := os.Stat(filePath); err == nil {
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
-4
View File
@@ -2092,22 +2092,18 @@ func SetLibraryCoverCacheDirJSON(cacheDir string) {
SetLibraryCoverCacheDir(cacheDir)
}
// ScanLibraryFolderJSON scans a folder for audio files and returns metadata
func ScanLibraryFolderJSON(folderPath string) (string, error) {
return ScanLibraryFolder(folderPath)
}
// GetLibraryScanProgressJSON returns current scan progress
func GetLibraryScanProgressJSON() string {
return GetLibraryScanProgress()
}
// CancelLibraryScanJSON cancels ongoing library scan
func CancelLibraryScanJSON() {
CancelLibraryScan()
}
// ReadAudioMetadataJSON reads metadata from a single audio file
func ReadAudioMetadataJSON(filePath string) (string, error) {
return ReadAudioMetadata(filePath)
}
+25 -146
View File
@@ -25,7 +25,7 @@ type ExtTrackMetadata struct {
AlbumArtist string `json:"album_artist,omitempty"`
DurationMS int `json:"duration_ms"`
CoverURL string `json:"cover_url,omitempty"`
Images string `json:"images,omitempty"` // Alternative field for cover URL (used by some extensions)
Images string `json:"images,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
@@ -33,19 +33,18 @@ type ExtTrackMetadata struct {
ProviderID string `json:"provider_id"`
ItemType string `json:"item_type,omitempty"`
AlbumType string `json:"album_type,omitempty"`
// Enrichment fields from Odesli/song.link
TidalID string `json:"tidal_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
SpotifyID string `json:"spotify_id,omitempty"`
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping
// Extended metadata from enrichment (can come from Deezer, Spotify, etc.)
Label string `json:"label,omitempty"` // Record label
Copyright string `json:"copyright,omitempty"` // Copyright information
Genre string `json:"genre,omitempty"` // Music genre(s)
ExternalLinks map[string]string `json:"external_links,omitempty"`
Label string `json:"label,omitempty"`
Copyright string `json:"copyright,omitempty"`
Genre string `json:"genre,omitempty"`
}
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
func (t *ExtTrackMetadata) ResolvedCoverURL() string {
if t.CoverURL != "" {
return t.CoverURL
@@ -53,7 +52,6 @@ func (t *ExtTrackMetadata) ResolvedCoverURL() string {
return t.Images
}
// ExtAlbumMetadata represents album metadata from an extension
type ExtAlbumMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -67,34 +65,28 @@ type ExtAlbumMetadata struct {
ProviderID string `json:"provider_id"`
}
// ExtArtistMetadata represents artist metadata from an extension
type ExtArtistMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
ImageURL string `json:"image_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background
Listeners int `json:"listeners,omitempty"` // Monthly listeners
HeaderImage string `json:"header_image,omitempty"`
Listeners int `json:"listeners,omitempty"`
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` // Popular tracks
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
ProviderID string `json:"provider_id"`
}
// ExtSearchResult represents search results from an extension
type ExtSearchResult struct {
Tracks []ExtTrackMetadata `json:"tracks"`
Total int `json:"total"`
}
// ==================== Download Types ====================
// ExtAvailabilityResult represents availability check result
type ExtAvailabilityResult struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
TrackID string `json:"track_id,omitempty"`
}
// ExtDownloadURLResult represents download URL info
type ExtDownloadURLResult struct {
URL string `json:"url"`
Format string `json:"format"`
@@ -102,7 +94,6 @@ type ExtDownloadURLResult struct {
SampleRate int `json:"sample_rate,omitempty"`
}
// ExtDownloadResult represents download result from an extension
type ExtDownloadResult struct {
Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"`
@@ -110,7 +101,7 @@ type ExtDownloadResult struct {
SampleRate int `json:"sample_rate,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
// Metadata returned by extension (optional - if provided, can skip enrichment)
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
@@ -122,15 +113,11 @@ type ExtDownloadResult struct {
ISRC string `json:"isrc,omitempty"`
}
// ==================== Provider Wrapper ====================
// ExtensionProviderWrapper wraps an extension to call its provider methods
type ExtensionProviderWrapper struct {
extension *LoadedExtension
vm *goja.Runtime
}
// NewExtensionProviderWrapper creates a new provider wrapper
func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper {
return &ExtensionProviderWrapper{
extension: ext,
@@ -138,9 +125,6 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
}
}
// ==================== Metadata Provider Methods ====================
// SearchTracks searches for tracks using the extension
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -150,11 +134,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Call extension's searchTracks function
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.searchTracks === 'function') {
@@ -184,14 +166,11 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
var searchResult ExtSearchResult
// Try to parse as ExtSearchResult object first
if err := json.Unmarshal(jsonBytes, &searchResult); err != nil {
// If that fails, try parsing as array of tracks directly
var tracks []ExtTrackMetadata
if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil {
return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr)
}
// Wrap array in ExtSearchResult
searchResult = ExtSearchResult{
Tracks: tracks,
Total: len(tracks),
@@ -205,7 +184,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
return &searchResult, nil
}
// GetTrack gets track details by ID
func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -215,7 +193,6 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -255,7 +232,6 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
return &track, nil
}
// GetAlbum gets album details by ID
func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -265,7 +241,6 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -308,7 +283,6 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
return &album, nil
}
// GetArtist gets artist details by ID
func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -318,7 +292,6 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -358,23 +331,18 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
return &artist, nil
}
// EnrichTrack enriches track metadata before download (e.g., fetch real ISRC)
// This is called lazily when download starts, not when playlist/album is loaded
// Extension should implement enrichTrack(track) function that returns enriched track
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return track, nil // Not a metadata provider, return as-is
return track, nil
}
if !p.extension.Enabled {
return track, nil // Extension disabled, return as-is
return track, nil
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Convert track to JSON for passing to JS
trackJSON, err := json.Marshal(track)
if err != nil {
GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err)
@@ -401,7 +369,6 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
return track, nil
}
// If extension doesn't implement enrichTrack or returns null, return original
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return track, nil
}
@@ -419,18 +386,11 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
return track, nil
}
// Preserve provider ID
enrichedTrack.ProviderID = track.ProviderID
GoLog("[Extension] EnrichTrack: enriched track from %s (ISRC: %s -> %s)\n",
p.extension.ID, track.ISRC, enrichedTrack.ISRC)
return &enrichedTrack, nil
}
// ==================== Download Provider Methods ====================
// CheckAvailability checks if a track is available for download
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
@@ -440,7 +400,6 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -479,7 +438,6 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
return &availability, nil
}
// GetDownloadURL gets the download URL for a track
func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
@@ -489,7 +447,6 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -528,10 +485,8 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return &urlResult, nil
}
// ExtDownloadTimeout is longer for extension download operations (5 minutes)
const ExtDownloadTimeout = 5 * time.Minute
// Download downloads a track with progress reporting
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
@@ -541,15 +496,12 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Set up progress callback in VM
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
percent := int(call.Arguments[0].ToInteger())
// Clamp to 0-100
if percent < 0 {
percent = 0
}
@@ -572,7 +524,6 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
})()
`, trackID, quality, outputPath)
// Use longer timeout for downloads (5 minutes)
result, err := RunWithTimeoutAndRecover(p.vm, script, ExtDownloadTimeout)
if err != nil {
errMsg := err.Error()
@@ -618,9 +569,6 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
return &downloadResult, nil
}
// ==================== Extension Manager Provider Methods ====================
// GetMetadataProviders returns all enabled metadata provider extensions
func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -634,7 +582,6 @@ func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
return providers
}
// GetDownloadProviders returns all enabled download provider extensions
func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -648,7 +595,6 @@ func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
return providers
}
// SearchTracksWithExtensions searches all metadata providers
func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
providers := m.GetMetadataProviders()
if len(providers) == 0 {
@@ -670,18 +616,12 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
return allTracks, nil
}
// ==================== Provider Priority ====================
// providerPriority stores the order of download providers
var providerPriority []string
var providerPriorityMu sync.RWMutex
// metadataProviderPriority stores the order of metadata providers
var metadataProviderPriority []string
var metadataProviderPriorityMu sync.RWMutex
// SetProviderPriority sets the order of download providers
// providerIDs should include both built-in ("tidal", "qobuz", "amazon") and extension IDs
func SetProviderPriority(providerIDs []string) {
providerPriorityMu.Lock()
defer providerPriorityMu.Unlock()
@@ -689,13 +629,11 @@ func SetProviderPriority(providerIDs []string) {
GoLog("[Extension] Download provider priority set: %v\n", providerIDs)
}
// GetProviderPriority returns the current provider priority order
func GetProviderPriority() []string {
providerPriorityMu.RLock()
defer providerPriorityMu.RUnlock()
if len(providerPriority) == 0 {
// Default order: built-in providers first
return []string{"tidal", "qobuz", "amazon"}
}
@@ -704,8 +642,6 @@ func GetProviderPriority() []string {
return result
}
// SetMetadataProviderPriority sets the order of metadata providers
// providerIDs should include both built-in ("spotify", "deezer") and extension IDs
func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock()
@@ -713,13 +649,11 @@ func SetMetadataProviderPriority(providerIDs []string) {
GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs)
}
// GetMetadataProviderPriority returns the current metadata provider priority order
func GetMetadataProviderPriority() []string {
metadataProviderPriorityMu.RLock()
defer metadataProviderPriorityMu.RUnlock()
if len(metadataProviderPriority) == 0 {
// Default order: built-in providers first
return []string{"deezer", "spotify"}
}
@@ -728,7 +662,6 @@ func GetMetadataProviderPriority() []string {
return result
}
// isBuiltInProvider checks if a provider ID is a built-in provider
func isBuiltInProvider(providerID string) bool {
switch providerID {
case "tidal", "qobuz", "amazon", "deezer":
@@ -738,20 +671,12 @@ func isBuiltInProvider(providerID string) bool {
}
}
// ==================== Download with Fallback ====================
// DownloadWithExtensionFallback tries to download from providers in priority order
// Includes both built-in providers and extension providers
// If req.Source is set (extension ID), that extension is tried first
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
priority := GetProviderPriority()
extManager := GetExtensionManager()
// If req.Service is a built-in provider, prioritize it first
// This handles user's explicit selection from the service picker
if req.Service != "" && isBuiltInProvider(req.Service) {
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
// Reorder priority to put req.Service first
newPriority := []string{req.Service}
for _, p := range priority {
if p != req.Service {
@@ -763,10 +688,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
var lastErr error
var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers
var skipBuiltIn bool
// LAZY ENRICHMENT: If track came from an extension, try to enrich metadata (e.g., get real ISRC)
// This is done lazily at download time, not when playlist/album is loaded
if req.Source != "" && !isBuiltInProvider(req.Source) {
ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
@@ -810,7 +733,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if enrichedTrack.Artists != "" {
req.ArtistName = enrichedTrack.Artists
}
// Copy extended metadata from enrichment (label, copyright, genre, release_date)
if enrichedTrack.Label != "" && req.Label == "" {
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
req.Label = enrichedTrack.Label
@@ -831,7 +753,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
// If source extension is specified, try it first before the priority list
if req.Source != "" && !isBuiltInProvider(req.Source) {
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source)
@@ -841,7 +762,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
provider := NewExtensionProviderWrapper(ext)
// For tracks from extension search, use the track ID directly (e.g., "youtube:VIDEO_ID")
trackID := req.SpotifyID
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
@@ -867,7 +787,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Copyright: req.Copyright,
}
// Embed genre and label if provided (from Deezer metadata)
if req.Genre != "" || req.Label != "" {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
@@ -925,12 +844,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
// If skipBuiltInFallback is true, don't continue to other providers
if skipBuiltIn {
GoLog("[DownloadWithExtensionFallback] skipBuiltInFallback is true, not trying other providers\n")
return &DownloadResponse{
Success: false,
Error: fmt.Sprintf("Download failed: %v", lastErr),
Error: "Download failed: " + lastErr.Error(),
ErrorType: "extension_error",
Service: req.Source,
}, nil
@@ -940,7 +858,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
// Continue with priority list
for _, providerID := range priority {
if providerID == req.Source {
continue
@@ -954,7 +871,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(providerID) {
// For built-in providers, enrich with Deezer metadata if not already present
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -975,11 +891,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
// Use built-in provider
result, err := tryBuiltInProvider(providerID, req)
if err == nil && result.Success {
result.Service = providerID
// Copy enriched metadata to response for Flutter (needed for M4A->FLAC conversion)
if req.Label != "" {
result.Label = req.Label
}
@@ -1007,7 +921,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
}
} else {
// Try extension provider
ext, err := extManager.GetExtension(providerID)
if err != nil || !ext.Enabled || ext.Error != "" {
GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID)
@@ -1050,7 +963,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Copyright: req.Copyright,
}
// Embed genre and label if provided (from Deezer metadata)
if req.Genre != "" || req.Label != "" {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
@@ -1061,7 +973,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if ext.Manifest.SkipMetadataEnrichment {
resp.SkipMetadataEnrichment = true
// Copy metadata from extension result if provided
if result.Title != "" {
resp.Title = result.Title
}
@@ -1114,7 +1025,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if lastErr != nil {
return &DownloadResponse{
Success: false,
Error: fmt.Sprintf("All providers failed. Last error: %v", lastErr),
Error: "All providers failed. Last error: " + lastErr.Error(),
ErrorType: "not_found",
}, nil
}
@@ -1126,7 +1037,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}, nil
}
// tryBuiltInProvider attempts download from a built-in provider
func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadResponse, error) {
req.Service = providerID
@@ -1212,7 +1122,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
}, nil
}
// buildOutputPath builds the output file path from request
func buildOutputPath(req DownloadRequest) string {
metadata := map[string]interface{}{
"title": req.TrackName,
@@ -1232,9 +1141,6 @@ func buildOutputPath(req DownloadRequest) string {
return fmt.Sprintf("%s/%s.flac", req.OutputDir, filename)
}
// ==================== Custom Search ====================
// CustomSearch performs a custom search using an extension's search function
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
if !p.extension.Manifest.HasCustomSearch() {
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
@@ -1244,11 +1150,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
// Convert options to JSON
optionsJSON, _ := json.Marshal(options)
script := fmt.Sprintf(`
@@ -1294,20 +1198,16 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
return tracks, nil
}
// ==================== Custom URL Handler ====================
// ExtURLHandleResult represents the result of URL handling
type ExtURLHandleResult struct {
Type string `json:"type"` // "track", "album", "playlist", "artist"
Track *ExtTrackMetadata `json:"track,omitempty"` // For single track
Tracks []ExtTrackMetadata `json:"tracks,omitempty"` // For album/playlist
Album *ExtAlbumMetadata `json:"album,omitempty"` // Album info
Artist *ExtArtistMetadata `json:"artist,omitempty"` // Artist info
Name string `json:"name,omitempty"` // Playlist/album name
CoverURL string `json:"cover_url,omitempty"` // Cover image
Type string `json:"type"`
Track *ExtTrackMetadata `json:"track,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
Album *ExtAlbumMetadata `json:"album,omitempty"`
Artist *ExtArtistMetadata `json:"artist,omitempty"`
Name string `json:"name,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
}
// HandleURL processes a URL using the extension's URL handler
func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
if !p.extension.Manifest.HasURLHandler() {
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
@@ -1317,7 +1217,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -1381,9 +1280,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
return &handleResult, nil
}
// ==================== Custom Track Matching ====================
// MatchTrackResult represents the result of custom track matching
type MatchTrackResult struct {
Matched bool `json:"matched"`
TrackID string `json:"track_id,omitempty"`
@@ -1391,7 +1287,6 @@ type MatchTrackResult struct {
Reason string `json:"reason,omitempty"`
}
// MatchTrack uses extension's custom matching algorithm
func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) {
if !p.extension.Manifest.HasCustomMatching() {
return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID)
@@ -1401,7 +1296,6 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -1443,22 +1337,16 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
return &matchResult, nil
}
// ==================== Post-Processing ====================
// PostProcessResult represents the result of post-processing
type PostProcessResult struct {
Success bool `json:"success"`
NewFilePath string `json:"new_file_path,omitempty"`
Error string `json:"error,omitempty"`
// Additional metadata that may have changed
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
}
// PostProcessTimeout is longer for post-processing (2 minutes)
const PostProcessTimeout = 2 * time.Minute
// PostProcess runs post-processing hooks on a downloaded file
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
if !p.extension.Manifest.HasPostProcessing() {
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
@@ -1468,7 +1356,6 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
// Lock VM to prevent concurrent access
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
@@ -1522,9 +1409,6 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
return &postResult, nil
}
// ==================== Extension Manager Advanced Methods ====================
// GetSearchProviders returns all extensions that provide custom search
func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -1538,7 +1422,6 @@ func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
return providers
}
// GetURLHandlers returns all extensions that handle custom URLs
func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -1552,7 +1435,6 @@ func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
return providers
}
// FindURLHandler finds an extension that can handle the given URL
func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -1565,14 +1447,11 @@ func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper
return nil
}
// ExtURLHandleResultWithExtID wraps ExtURLHandleResult with extension ID for gomobile compatibility
type ExtURLHandleResultWithExtID struct {
Result *ExtURLHandleResult
ExtensionID string
}
// HandleURLWithExtension tries to handle a URL with any matching extension
// Returns result with extension ID, or error if no handler found
func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
handler := m.FindURLHandler(url)
if handler == nil {
-12
View File
@@ -64,7 +64,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
command := call.Arguments[0].String()
// Generate unique command ID
ffmpegCommandsMu.Lock()
ffmpegCommandID++
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
@@ -77,7 +76,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
// Wait for completion (with timeout)
timeout := 5 * time.Minute
start := time.Now()
for {
@@ -97,7 +95,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
}
ffmpegCommandsMu.RUnlock()
// Cleanup
ClearFFmpegCommand(cmdID)
return r.vm.ToValue(result)
}
@@ -124,7 +121,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
filePath := call.Arguments[0].String()
// Use Go's built-in audio quality function
quality, err := GetAudioQuality(filePath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -153,7 +149,6 @@ func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
inputPath := call.Arguments[0].String()
outputPath := call.Arguments[1].String()
// Get options if provided
options := map[string]interface{}{}
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
@@ -161,36 +156,29 @@ func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
}
}
// Build FFmpeg command
var cmdParts []string
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
// Audio codec
if codec, ok := options["codec"].(string); ok {
cmdParts = append(cmdParts, "-c:a", codec)
}
// Bitrate
if bitrate, ok := options["bitrate"].(string); ok {
cmdParts = append(cmdParts, "-b:a", bitrate)
}
// Sample rate
if sampleRate, ok := options["sample_rate"].(float64); ok {
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
}
// Channels
if channels, ok := options["channels"].(float64); ok {
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
}
// Overwrite output
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
command := strings.Join(cmdParts, " ")
// Execute via ffmpegExecute
execCall := goja.FunctionCall{
Arguments: []goja.Value{r.vm.ToValue(command)},
}
-1
View File
@@ -349,7 +349,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
})
}
// Create directory if needed
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return r.vm.ToValue(map[string]interface{}{
+1 -23
View File
@@ -40,7 +40,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
optionsObj := call.Arguments[1].Export()
if opts, ok := optionsObj.(map[string]interface{}); ok {
// Method
if m, ok := opts["method"].(string); ok {
method = strings.ToUpper(m)
}
@@ -61,7 +60,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
}
}
// Headers
if h, ok := opts["headers"]; ok && h != nil {
switch hv := h.(type) {
case map[string]interface{}:
@@ -73,7 +71,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
}
}
// Create HTTP request
var reqBody io.Reader
if bodyStr != "" {
reqBody = strings.NewReader(bodyStr)
@@ -84,7 +81,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
return r.createFetchError(err.Error())
}
// Set headers - user headers first
for k, v := range headers {
req.Header.Set(k, v)
}
@@ -96,20 +92,17 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
req.Header.Set("Content-Type", "application/json")
}
// Execute request
resp, err := r.httpClient.Do(req)
if err != nil {
return r.createFetchError(err.Error())
}
defer resp.Body.Close()
// Read body
body, err := io.ReadAll(resp.Body)
if err != nil {
return r.createFetchError(err.Error())
}
// Extract response headers
respHeaders := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
@@ -127,15 +120,12 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
responseObj.Set("headers", respHeaders)
responseObj.Set("url", urlStr)
// Store body for methods
bodyString := string(body)
// text() method - returns body as string
responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(bodyString)
})
// json() method - parses body as JSON
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
var result interface{}
if err := json.Unmarshal(body, &result); err != nil {
@@ -145,7 +135,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result)
})
// arrayBuffer() method - returns body as array (simplified)
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
// Return as array of bytes
byteArray := make([]interface{}, len(body))
@@ -208,7 +197,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
encoder := call.This
encoder.Set("encoding", "utf-8")
// encode() method - string to Uint8Array
encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue([]byte{})
@@ -224,7 +212,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
return vm.ToValue(result)
})
// encodeInto() method
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
// Simplified implementation
if len(call.Arguments) < 2 {
@@ -253,7 +240,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
decoder.Set("fatal", false)
decoder.Set("ignoreBOM", false)
// decode() method - Uint8Array to string
decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue("")
@@ -292,7 +278,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
})
}
// registerURLClass registers the URL class for URL parsing
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
urlObj := call.This
@@ -322,7 +307,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
return nil
}
// Set URL properties
urlObj.Set("href", parsed.String())
urlObj.Set("protocol", parsed.Scheme+":")
urlObj.Set("host", parsed.Host)
@@ -342,10 +326,9 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
password, _ := parsed.User.Password()
urlObj.Set("password", password)
// searchParams object
searchParams := vm.NewObject()
queryValues := parsed.Query()
searchParams := vm.NewObject()
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return goja.Null()
@@ -379,12 +362,10 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
urlObj.Set("searchParams", searchParams)
// toString method
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(parsed.String())
})
// toJSON method
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
return vm.ToValue(parsed.String())
})
@@ -392,17 +373,14 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
return nil
})
// URLSearchParams constructor
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
paramsObj := call.This
values := url.Values{}
// Parse initial value if provided
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
init := call.Arguments[0].Export()
switch v := init.(type) {
case string:
// Parse query string
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
values = parsed
case map[string]interface{}:
-13
View File
@@ -174,7 +174,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
return string(jsonBytes), nil
}
// scanAudioFile reads metadata from a single audio file
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
ext := strings.ToLower(filepath.Ext(filePath))
@@ -209,7 +208,6 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
}
}
// scanFLACFile reads metadata from FLAC file
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadMetadata(filePath)
if err != nil {
@@ -248,7 +246,6 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
return result, nil
}
// scanM4AFile reads metadata from M4A/AAC file
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
quality, err := GetM4AQuality(filePath)
if err == nil {
@@ -259,7 +256,6 @@ func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
return scanFromFilename(filePath, result)
}
// scanMP3File reads metadata from MP3 file (ID3 tags)
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath)
if err != nil {
@@ -301,7 +297,6 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
return result, nil
}
// scanOggFile reads metadata from Ogg Vorbis/Opus file (Vorbis comments)
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadOggVorbisComments(filePath)
if err != nil {
@@ -339,7 +334,6 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
return result, nil
}
// scanFromFilename extracts title/artist from filename pattern
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
@@ -371,7 +365,6 @@ func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanR
return result, nil
}
// isNumeric checks if string contains only digits
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
@@ -381,12 +374,10 @@ func isNumeric(s string) bool {
return len(s) > 0
}
// generateLibraryID creates a unique ID for a library item
func generateLibraryID(filePath string) string {
return fmt.Sprintf("lib_%x", hashString(filePath))
}
// hashString creates a simple hash of a string
func hashString(s string) uint32 {
var hash uint32 = 5381
for _, c := range s {
@@ -395,7 +386,6 @@ func hashString(s string) uint32 {
return hash
}
// GetLibraryScanProgress returns current scan progress
func GetLibraryScanProgress() string {
libraryScanProgressMu.RLock()
defer libraryScanProgressMu.RUnlock()
@@ -404,7 +394,6 @@ func GetLibraryScanProgress() string {
return string(jsonBytes)
}
// CancelLibraryScan cancels ongoing library scan
func CancelLibraryScan() {
libraryScanCancelMu.Lock()
defer libraryScanCancelMu.Unlock()
@@ -415,8 +404,6 @@ func CancelLibraryScan() {
}
}
// ReadAudioMetadata reads metadata from any supported audio file
// Returns JSON with track info
func ReadAudioMetadata(filePath string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
result, err := scanAudioFile(filePath, scanTime)
@@ -1019,10 +1019,6 @@ void removeItem(String id) {
_saveQueueToStorage();
}
/// Export failed downloads to a TXT file
/// Returns the file path if successful, null otherwise
/// Uses daily files: same day = append to existing file, new day = new file
/// Saves to 'failed_downloads' subfolder to keep organized
Future<String?> exportFailedDownloads() async {
final failedItems = state.items
.where((item) => item.status == DownloadStatus.failed)
@@ -1091,7 +1087,6 @@ void removeItem(String id) {
}
}
/// Clear all failed downloads from queue
void clearFailedDownloads() {
final items = state.items
.where((item) => item.status != DownloadStatus.failed)
-16
View File
@@ -41,25 +41,20 @@ class LocalLibraryState {
.map((item) => MapEntry(item.isrc!, item)),
);
/// Check if ISRC exists in library
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
/// Check if track exists by name and artist
bool hasTrack(String trackName, String artistName) {
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
return _trackKeySet.contains(key);
}
/// Find library item by ISRC
LocalLibraryItem? getByIsrc(String isrc) => _byIsrc[isrc];
/// Find library item by track name and artist
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
return items.where((item) => item.matchKey == key).firstOrNull;
}
/// Check if a track exists in library (by ISRC or name matching)
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
if (isrc != null && isrc.isNotEmpty && hasIsrc(isrc)) {
return true;
@@ -136,13 +131,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
}
/// Reload library from database
Future<void> reloadFromStorage() async {
_isLoaded = false;
await _loadFromDatabase();
}
/// Start scanning a folder for audio files
Future<void> startScan(String folderPath) async {
if (state.isScanning) {
_log.w('Scan already in progress');
@@ -230,7 +223,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_progressTimer = null;
}
/// Cancel ongoing scan
Future<void> cancelScan() async {
if (!state.isScanning) return;
@@ -240,7 +232,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_stopProgressPolling();
}
/// Clean up missing files from library
Future<int> cleanupMissingFiles() async {
final removed = await _db.cleanupMissingFiles();
if (removed > 0) {
@@ -249,7 +240,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return removed;
}
/// Clear all library data
Future<void> clearLibrary() async {
await _db.clearAll();
@@ -264,7 +254,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Library cleared');
}
/// Remove a single item from library by ID
Future<void> removeItem(String id) async {
await _db.delete(id);
state = state.copyWith(
@@ -272,7 +261,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
}
/// Check if a track exists in library
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
return state.existsInLibrary(
isrc: isrc,
@@ -281,12 +269,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
}
/// Get library item by ISRC
LocalLibraryItem? getByIsrc(String isrc) {
return state.getByIsrc(isrc);
}
/// Find library item for a track
LocalLibraryItem? findExisting({String? isrc, String? trackName, String? artistName}) {
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = state.getByIsrc(isrc);
@@ -298,7 +284,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return null;
}
/// Search library
Future<List<LocalLibraryItem>> search(String query) async {
if (query.isEmpty) return [];
@@ -306,7 +291,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return results.map((e) => LocalLibraryItem.fromJson(e)).toList();
}
/// Get library count
Future<int> getCount() async {
return await _db.getCount();
}
+10 -20
View File
@@ -45,10 +45,10 @@ class AlbumScreen extends ConsumerStatefulWidget {
final String albumId;
final String albumName;
final String? coverUrl;
final List<Track>? tracks; // Optional - will fetch if null
final String? extensionId; // If from extension
final String? artistId; // Artist ID for navigation
final String? artistName; // Artist name for navigation
final List<Track>? tracks;
final String? extensionId;
final String? artistId;
final String? artistName;
const AlbumScreen({
super.key,
@@ -93,13 +93,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
});
// Use provided tracks if not empty, otherwise try cache
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
_tracks = widget.tracks;
} else {
_tracks = _AlbumCache.get(widget.albumId);
}
_artistId = widget.artistId; // Use provided artist ID if available
_artistId = widget.artistId;
if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks();
@@ -122,7 +121,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
Future<void> _extractDominantColor() async {
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null) {
@@ -131,21 +130,18 @@ Future<void> _extractDominantColor() async {
}
String _formatReleaseDate(String date) {
// Handle formats: "2024-01-15", "2024-01", "2024"
if (date.length >= 10) {
// Full date: 2024-01-15
final parts = date.substring(0, 10).split('-');
if (parts.length == 3) {
return '${parts[2]}/${parts[1]}/${parts[0]}'; // DD/MM/YYYY
return '${parts[2]}/${parts[1]}/${parts[0]}';
}
} else if (date.length >= 7) {
// Month: 2024-01
final parts = date.split('-');
if (parts.length >= 2) {
return '${parts[1]}/${parts[0]}'; // MM/YYYY
return '${parts[1]}/${parts[0]}';
}
}
return date; // Year only or unknown format
return date;
}
Future<void> _fetchTracks() async {
@@ -164,7 +160,6 @@ Future<void> _fetchTracks() async {
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
// Extract artist ID from album_info if available
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = albumInfo?['artist_id'] as String?;
@@ -236,7 +231,7 @@ Future<void> _fetchTracks() async {
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
@@ -269,7 +264,6 @@ Future<void> _fetchTracks() async {
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
@@ -501,11 +495,9 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
}
void _navigateToArtist(BuildContext context, String artistName) {
// Use stored artist ID if available, otherwise use a placeholder
final artistId = _artistId ??
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
// Don't navigate if artist ID is unknown
if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Artist information not available')),
@@ -513,7 +505,6 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
return;
}
// If from extension, use ExtensionArtistScreen
if (widget.extensionId != null) {
Navigator.push(
context,
@@ -621,7 +612,6 @@ class _AlbumTrackItem extends ConsumerWidget {
return state.isDownloaded(track.id);
}));
// Check local library for duplicate detection
final settings = ref.watch(settingsProvider);
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
final isInLocalLibrary = showLocalLibraryIndicator
+1 -1
View File
@@ -74,7 +74,7 @@ class ArtistScreen extends ConsumerStatefulWidget {
final int? monthlyListeners;
final List<ArtistAlbum>? albums;
final List<Track>? topTracks;
final String? extensionId; // If set, skip fetching from Spotify/Deezer
final String? extensionId;
const ArtistScreen({
super.key,
-9
View File
@@ -50,19 +50,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
late final ProviderSubscription<TrackState> _trackStateSub;
late final ProviderSubscription<bool> _extensionInitSub;
/// Debounce timer for live search (extension-only feature)
Timer? _liveSearchDebounce;
/// Flag to prevent concurrent live search calls (prevents race conditions in extensions)
bool _isLiveSearchInProgress = false;
/// Pending query to execute after current search completes
String? _pendingLiveSearchQuery;
/// Minimum characters required to trigger live search
static const int _minLiveSearchChars = 3;
/// Debounce duration for live search
static const Duration _liveSearchDelay = Duration(milliseconds: 800);
List<DownloadHistoryItem>? _recentAccessHistoryCache;
+1 -1
View File
@@ -17,7 +17,7 @@ class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName;
final String? coverUrl;
final List<Track> tracks;
final String? playlistId; // Deezer playlist ID for fetching tracks
final String? playlistId;
const PlaylistScreen({
super.key,
-25
View File
@@ -567,7 +567,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
});
}
/// Exit selection mode
void _exitSelectionMode() {
setState(() {
_isSelectionMode = false;
@@ -588,25 +587,20 @@ class _QueueTabState extends ConsumerState<QueueTab> {
});
}
/// Select all visible items
void _selectAll(List<UnifiedLibraryItem> items) {
setState(() {
_selectedIds.addAll(items.map((e) => e.id));
});
}
/// Get short badge text for quality display
String _getQualityBadgeText(String quality) {
// For lossless: "24-bit/96kHz" -> "24-bit"
if (quality.contains('bit')) {
return quality.split('/').first;
}
// For lossy: "OPUS 128kbps" -> "128k", "MP3 320kbps" -> "320k"
final bitrateMatch = RegExp(r'(\d+)kbps').firstMatch(quality);
if (bitrateMatch != null) {
return '${bitrateMatch.group(1)}k';
}
// Fallback: return format name
return quality.split(' ').first;
}
@@ -725,7 +719,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
});
}
/// Count of active advanced filters
int get _activeFilterCount {
int count = 0;
if (_filterSource != null) count++;
@@ -735,7 +728,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return count;
}
/// Reset all advanced filters
void _resetFilters() {
setState(() {
_filterSource = null;
@@ -746,12 +738,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
});
}
/// Apply advanced filters to unified items
List<UnifiedLibraryItem> _applyAdvancedFilters(List<UnifiedLibraryItem> items) {
if (_activeFilterCount == 0) return items;
return items.where((item) {
// Source filter
if (_filterSource != null) {
if (_filterSource == 'downloaded' && item.source != LibraryItemSource.downloaded) {
return false;
@@ -761,7 +751,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
// Quality filter
if (_filterQuality != null && item.quality != null) {
final quality = item.quality!.toLowerCase();
switch (_filterQuality) {
@@ -770,21 +759,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
case 'cd':
if (!quality.startsWith('16')) return false;
case 'lossy':
// Lossy formats typically don't have bit depth or are labeled differently
if (quality.startsWith('24') || quality.startsWith('16')) return false;
}
} else if (_filterQuality != null && item.quality == null) {
// If quality filter is set but item has no quality info, include only for 'lossy'
if (_filterQuality != 'lossy') return false;
}
// Format filter
if (_filterFormat != null) {
final ext = item.filePath.split('.').last.toLowerCase();
if (ext != _filterFormat) return false;
}
// Date filter
if (_filterDateRange != null) {
final now = DateTime.now();
final itemDate = item.addedAt;
@@ -808,7 +793,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}).toList(growable: false);
}
/// Get available formats from current items
Set<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
final formats = <String>{};
for (final item in items) {
@@ -820,12 +804,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return formats;
}
/// Show filter bottom sheet
void _showFilterSheet(BuildContext context, List<UnifiedLibraryItem> allItems) {
final colorScheme = Theme.of(context).colorScheme;
final availableFormats = _getAvailableFormats(allItems);
// Temporary filter state for the sheet
String? tempSource = _filterSource;
String? tempQuality = _filterQuality;
String? tempFormat = _filterFormat;
@@ -847,7 +829,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Handle bar
Center(
child: Container(
width: 32,
@@ -860,7 +841,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// Title row
Row(
children: [
Text(
@@ -885,7 +865,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(height: 16),
// Source filter
Text(
context.l10n.libraryFilterSource,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
@@ -915,7 +894,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(height: 16),
// Quality filter
Text(
context.l10n.libraryFilterQuality,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
@@ -950,7 +928,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(height: 16),
// Format filter
Text(
context.l10n.libraryFilterFormat,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
@@ -976,7 +953,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(height: 16),
// Date filter
Text(
context.l10n.libraryFilterDate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
@@ -1016,7 +992,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
const SizedBox(height: 24),
// Apply button
SizedBox(
width: double.infinity,
child: FilledButton(
-4
View File
@@ -27,7 +27,6 @@ class CoverCacheManager {
return _instance!;
}
/// Check if cache manager is initialized
static bool get isInitialized => _initialized && _instance != null;
static Future<void> initialize() async {
@@ -62,8 +61,6 @@ class CoverCacheManager {
}
}
/// Clear all cached cover images.
/// Returns the number of files deleted.
static Future<void> clearCache() async {
if (!_initialized || _instance == null) return;
await _instance!.emptyCache();
@@ -98,7 +95,6 @@ class CoverCacheManager {
}
}
/// Statistics about the cover image cache
class CacheStats {
final int fileCount;
final int totalSizeBytes;
-1
View File
@@ -154,7 +154,6 @@ class LogBuffer extends ChangeNotifier {
return buffer.toString();
}
/// Export logs with device information for debugging
Future<String> exportWithDeviceInfo() async {
final buffer = StringBuffer();