mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-21 07:26:51 +02:00
refactor: remove more redundant comments
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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)},
|
||||
}
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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{}:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user