mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-23 11:22:37 +02:00
refactor: continue code cleanup
This commit is contained in:
@@ -44,7 +44,6 @@ type OggQuality struct {
|
|||||||
// ID3 Tag Reading (MP3)
|
// ID3 Tag Reading (MP3)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// ReadID3Tags reads ID3v2 and ID3v1 tags from an MP3 file
|
|
||||||
func ReadID3Tags(filePath string) (*AudioMetadata, error) {
|
func ReadID3Tags(filePath string) (*AudioMetadata, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -87,7 +86,6 @@ func ReadID3Tags(filePath string) (*AudioMetadata, error) {
|
|||||||
return metadata, nil
|
return metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readID3v2 reads ID3v2 tags from the beginning of file
|
|
||||||
func readID3v2(file *os.File) (*AudioMetadata, error) {
|
func readID3v2(file *os.File) (*AudioMetadata, error) {
|
||||||
file.Seek(0, io.SeekStart)
|
file.Seek(0, io.SeekStart)
|
||||||
|
|
||||||
@@ -137,7 +135,6 @@ func readID3v2(file *os.File) (*AudioMetadata, error) {
|
|||||||
return metadata, nil
|
return metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseID3v22Frames parses ID3v2.2 frames (3-char frame IDs)
|
|
||||||
func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
||||||
pos := 0
|
pos := 0
|
||||||
for pos+6 < len(data) {
|
for pos+6 < len(data) {
|
||||||
@@ -286,7 +283,6 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// readID3v1 reads ID3v1 tag from end of file
|
|
||||||
func readID3v1(file *os.File) (*AudioMetadata, error) {
|
func readID3v1(file *os.File) (*AudioMetadata, error) {
|
||||||
if _, err := file.Seek(-128, io.SeekEnd); err != nil {
|
if _, err := file.Seek(-128, io.SeekEnd); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -321,7 +317,6 @@ func readID3v1(file *os.File) (*AudioMetadata, error) {
|
|||||||
return metadata, nil
|
return metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractTextFrame extracts text from ID3 text frame
|
|
||||||
func extractTextFrame(data []byte) string {
|
func extractTextFrame(data []byte) string {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -344,7 +339,6 @@ func extractTextFrame(data []byte) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeUTF16 decodes UTF-16 with BOM
|
|
||||||
func decodeUTF16(data []byte) string {
|
func decodeUTF16(data []byte) string {
|
||||||
if len(data) < 2 {
|
if len(data) < 2 {
|
||||||
return ""
|
return ""
|
||||||
@@ -362,12 +356,10 @@ func decodeUTF16(data []byte) string {
|
|||||||
return decodeUTF16Data(data, littleEndian)
|
return decodeUTF16Data(data, littleEndian)
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeUTF16BE decodes UTF-16 Big Endian
|
|
||||||
func decodeUTF16BE(data []byte) string {
|
func decodeUTF16BE(data []byte) string {
|
||||||
return decodeUTF16Data(data, false)
|
return decodeUTF16Data(data, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeUTF16Data decodes UTF-16 data
|
|
||||||
func decodeUTF16Data(data []byte, littleEndian bool) string {
|
func decodeUTF16Data(data []byte, littleEndian bool) string {
|
||||||
if len(data) < 2 {
|
if len(data) < 2 {
|
||||||
return ""
|
return ""
|
||||||
@@ -389,13 +381,11 @@ func decodeUTF16Data(data []byte, littleEndian bool) string {
|
|||||||
return string(runes)
|
return string(runes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanGenre removes ID3 genre number format like "(17)" or "(17)Rock"
|
|
||||||
func cleanGenre(genre string) string {
|
func cleanGenre(genre string) string {
|
||||||
if len(genre) == 0 {
|
if len(genre) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle "(17)" or "(17)Rock" format
|
|
||||||
if genre[0] == '(' {
|
if genre[0] == '(' {
|
||||||
end := strings.Index(genre, ")")
|
end := strings.Index(genre, ")")
|
||||||
if end > 0 {
|
if end > 0 {
|
||||||
@@ -411,7 +401,6 @@ func cleanGenre(genre string) string {
|
|||||||
return genre
|
return genre
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseTrackNumber extracts track number from "1/10" or "1" format
|
|
||||||
func parseTrackNumber(s string) int {
|
func parseTrackNumber(s string) int {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if idx := strings.Index(s, "/"); idx > 0 {
|
if idx := strings.Index(s, "/"); idx > 0 {
|
||||||
@@ -421,7 +410,6 @@ func parseTrackNumber(s string) int {
|
|||||||
return num
|
return num
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeUnsync removes ID3 unsynchronization (0xFF 0x00 -> 0xFF)
|
|
||||||
func removeUnsync(data []byte) []byte {
|
func removeUnsync(data []byte) []byte {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return data
|
return data
|
||||||
@@ -437,7 +425,6 @@ func removeUnsync(data []byte) []byte {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// extendedHeaderSize returns the total number of bytes to skip for the extended header
|
|
||||||
func extendedHeaderSize(data []byte, version byte) int {
|
func extendedHeaderSize(data []byte, version byte) int {
|
||||||
if len(data) < 4 {
|
if len(data) < 4 {
|
||||||
return 0
|
return 0
|
||||||
@@ -463,7 +450,6 @@ func extendedHeaderSize(data []byte, version byte) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncsafeToInt decodes a 4-byte syncsafe integer
|
|
||||||
func syncsafeToInt(b []byte) int {
|
func syncsafeToInt(b []byte) int {
|
||||||
if len(b) < 4 {
|
if len(b) < 4 {
|
||||||
return 0
|
return 0
|
||||||
@@ -471,7 +457,6 @@ func syncsafeToInt(b []byte) int {
|
|||||||
return int(b[0])<<21 | int(b[1])<<14 | int(b[2])<<7 | int(b[3])
|
return int(b[0])<<21 | int(b[1])<<14 | int(b[2])<<7 | int(b[3])
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstTextValue returns the first value in a null-separated text list
|
|
||||||
func firstTextValue(s string) string {
|
func firstTextValue(s string) string {
|
||||||
if idx := strings.IndexByte(s, 0); idx >= 0 {
|
if idx := strings.IndexByte(s, 0); idx >= 0 {
|
||||||
return s[:idx]
|
return s[:idx]
|
||||||
@@ -479,7 +464,6 @@ func firstTextValue(s string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMP3Quality reads MP3 audio quality info
|
|
||||||
func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -731,7 +715,6 @@ func detectOggStreamType(packets [][]byte) oggStreamType {
|
|||||||
return oggStreamUnknown
|
return oggStreamUnknown
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseVorbisComments parses Vorbis comment block
|
|
||||||
func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||||
if len(data) < 4 {
|
if len(data) < 4 {
|
||||||
return
|
return
|
||||||
@@ -807,7 +790,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOggQuality reads Ogg/Opus audio quality info
|
|
||||||
func GetOggQuality(filePath string) (*OggQuality, error) {
|
func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -906,7 +888,6 @@ var id3v1Genres = []string{
|
|||||||
// Cover Art Extraction
|
// Cover Art Extraction
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// extractMP3CoverArt extracts cover art from MP3 file (APIC frame)
|
|
||||||
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -976,7 +957,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
|||||||
return nil, "", fmt.Errorf("no cover art found")
|
return nil, "", fmt.Errorf("no cover art found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseAPICFrame parses APIC frame data
|
|
||||||
func parseAPICFrame(data []byte, version byte) ([]byte, string) {
|
func parseAPICFrame(data []byte, version byte) ([]byte, string) {
|
||||||
if len(data) < 4 {
|
if len(data) < 4 {
|
||||||
return nil, ""
|
return nil, ""
|
||||||
@@ -988,7 +968,6 @@ func parseAPICFrame(data []byte, version byte) ([]byte, string) {
|
|||||||
|
|
||||||
var mimeType string
|
var mimeType string
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
// ID3v2.2: 3-byte image format (JPG, PNG)
|
|
||||||
if pos+3 > len(data) {
|
if pos+3 > len(data) {
|
||||||
return nil, ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
@@ -1003,7 +982,6 @@ func parseAPICFrame(data []byte, version byte) ([]byte, string) {
|
|||||||
mimeType = "image/jpeg"
|
mimeType = "image/jpeg"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ID3v2.3/2.4: null-terminated MIME string
|
|
||||||
end := pos
|
end := pos
|
||||||
for end < len(data) && data[end] != 0 {
|
for end < len(data) && data[end] != 0 {
|
||||||
end++
|
end++
|
||||||
@@ -1018,15 +996,12 @@ func parseAPICFrame(data []byte, version byte) ([]byte, string) {
|
|||||||
|
|
||||||
pos++
|
pos++
|
||||||
|
|
||||||
// Skip description (null-terminated, may be UTF-16)
|
|
||||||
if encoding == 0 || encoding == 3 {
|
if encoding == 0 || encoding == 3 {
|
||||||
// ISO-8859-1 or UTF-8
|
|
||||||
for pos < len(data) && data[pos] != 0 {
|
for pos < len(data) && data[pos] != 0 {
|
||||||
pos++
|
pos++
|
||||||
}
|
}
|
||||||
pos++
|
pos++
|
||||||
} else {
|
} else {
|
||||||
// UTF-16: look for double null
|
|
||||||
for pos+1 < len(data) {
|
for pos+1 < len(data) {
|
||||||
if data[pos] == 0 && data[pos+1] == 0 {
|
if data[pos] == 0 && data[pos+1] == 0 {
|
||||||
pos += 2
|
pos += 2
|
||||||
@@ -1043,7 +1018,6 @@ func parseAPICFrame(data []byte, version byte) ([]byte, string) {
|
|||||||
return data[pos:], mimeType
|
return data[pos:], mimeType
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractOggCoverArt extracts cover art from Ogg/Opus file (METADATA_BLOCK_PICTURE)
|
|
||||||
func extractOggCoverArt(filePath string) ([]byte, string, error) {
|
func extractOggCoverArt(filePath string) ([]byte, string, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1087,7 +1061,6 @@ func extractOggCoverArt(filePath string) ([]byte, string, error) {
|
|||||||
return nil, "", fmt.Errorf("no cover art found")
|
return nil, "", fmt.Errorf("no cover art found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractPictureFromVorbisComments looks for METADATA_BLOCK_PICTURE in Vorbis comments
|
|
||||||
func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
|
func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
|
||||||
if len(data) < 8 {
|
if len(data) < 8 {
|
||||||
return nil, ""
|
return nil, ""
|
||||||
@@ -1109,13 +1082,12 @@ func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
|
|||||||
return nil, ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for METADATA_BLOCK_PICTURE
|
|
||||||
for i := uint32(0); i < commentCount && i < 100; i++ {
|
for i := uint32(0); i < commentCount && i < 100; i++ {
|
||||||
var commentLen uint32
|
var commentLen uint32
|
||||||
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
|
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if commentLen > 10000000 { // 10MB sanity check
|
if commentLen > 10000000 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1126,7 +1098,6 @@ func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
|
|||||||
|
|
||||||
key := "METADATA_BLOCK_PICTURE="
|
key := "METADATA_BLOCK_PICTURE="
|
||||||
if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key {
|
if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key {
|
||||||
// Base64-encoded FLAC picture block
|
|
||||||
b64Data := comment[len(key):]
|
b64Data := comment[len(key):]
|
||||||
decoded := make([]byte, base64StdDecodeLen(len(b64Data)))
|
decoded := make([]byte, base64StdDecodeLen(len(b64Data)))
|
||||||
n, err := base64StdDecode(decoded, b64Data)
|
n, err := base64StdDecode(decoded, b64Data)
|
||||||
@@ -1145,7 +1116,6 @@ func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
|
|||||||
return nil, ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseFLACPictureBlock parses FLAC PICTURE metadata block format
|
|
||||||
func parseFLACPictureBlock(data []byte) ([]byte, string) {
|
func parseFLACPictureBlock(data []byte) ([]byte, string) {
|
||||||
if len(data) < 32 {
|
if len(data) < 32 {
|
||||||
return nil, ""
|
return nil, ""
|
||||||
|
|||||||
@@ -735,7 +735,6 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDeezerMetadata fetches metadata from Deezer URL or ID
|
|
||||||
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -834,8 +833,6 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
|
|
||||||
// Useful when Spotify API is rate limited
|
|
||||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -885,7 +882,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
|||||||
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSpotifyMetadataWithDeezerFallback tries Spotify first, falls back to Deezer on rate limit
|
|
||||||
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -942,7 +938,6 @@ func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckAvailabilityByPlatformID checks track availability using any platform as source
|
|
||||||
func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) {
|
func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID)
|
availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ type LoadedExtension struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
VM *goja.Runtime `json:"-"`
|
VM *goja.Runtime `json:"-"`
|
||||||
VMMu sync.Mutex `json:"-"` // Mutex to prevent concurrent VM access
|
VMMu sync.Mutex `json:"-"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
DataDir string `json:"data_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
@@ -58,8 +58,8 @@ type LoadedExtension struct {
|
|||||||
type ExtensionManager struct {
|
type ExtensionManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
extensions map[string]*LoadedExtension
|
extensions map[string]*LoadedExtension
|
||||||
extensionsDir string // Base directory for extensions
|
extensionsDir string
|
||||||
dataDir string // Base directory for extension data
|
dataDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -98,7 +98,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open the zip file
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||||
@@ -221,7 +220,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Goja VM
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := m.initializeVM(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
@@ -268,13 +266,11 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Run the extension code
|
|
||||||
_, err = vm.RunString(string(jsCode))
|
_, err = vm.RunString(string(jsCode))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to execute extension code: %w", err)
|
return fmt.Errorf("failed to execute extension code: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify extension was registered
|
|
||||||
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
||||||
return fmt.Errorf("extension did not call registerExtension()")
|
return fmt.Errorf("extension did not call registerExtension()")
|
||||||
}
|
}
|
||||||
@@ -291,9 +287,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call cleanup if VM is initialized
|
|
||||||
if ext.VM != nil {
|
if ext.VM != nil {
|
||||||
// Try to call cleanup function
|
|
||||||
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
||||||
@@ -302,7 +296,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from registry
|
|
||||||
delete(m.extensions, extensionID)
|
delete(m.extensions, extensionID)
|
||||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||||
|
|
||||||
@@ -343,7 +336,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
|||||||
ext.Enabled = enabled
|
ext.Enabled = enabled
|
||||||
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||||
|
|
||||||
// Persist enabled state to settings store
|
|
||||||
store := GetExtensionSettingsStore()
|
store := GetExtensionSettingsStore()
|
||||||
if err := store.Set(extensionID, "_enabled", enabled); err != nil {
|
if err := store.Set(extensionID, "_enabled", enabled); err != nil {
|
||||||
GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err)
|
GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err)
|
||||||
@@ -438,7 +430,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Goja VM
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := m.initializeVM(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
@@ -457,12 +448,10 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unload first
|
|
||||||
if err := m.UnloadExtension(extensionID); err != nil {
|
if err := m.UnloadExtension(extensionID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove source directory
|
|
||||||
if ext.SourceDir != "" {
|
if ext.SourceDir != "" {
|
||||||
if err := os.RemoveAll(ext.SourceDir); err != nil {
|
if err := os.RemoveAll(ext.SourceDir); err != nil {
|
||||||
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
|
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
|
||||||
@@ -484,7 +473,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open the zip file
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||||
@@ -548,11 +536,9 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
extDir := existing.SourceDir
|
extDir := existing.SourceDir
|
||||||
wasEnabled := existing.Enabled
|
wasEnabled := existing.Enabled
|
||||||
|
|
||||||
// Cleanup and unload existing extension
|
|
||||||
m.CleanupExtension(existing.ID)
|
m.CleanupExtension(existing.ID)
|
||||||
m.UnloadExtension(existing.ID)
|
m.UnloadExtension(existing.ID)
|
||||||
|
|
||||||
// Remove old source files but keep data directory
|
|
||||||
if extDir != "" {
|
if extDir != "" {
|
||||||
if err := os.RemoveAll(extDir); err != nil {
|
if err := os.RemoveAll(extDir); err != nil {
|
||||||
GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err)
|
GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err)
|
||||||
@@ -634,11 +620,11 @@ type ExtensionUpgradeInfo struct {
|
|||||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||||
// Validate file extension
|
// Validate file extension
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open the zip file
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Cannot open extension file")
|
return nil, fmt.Errorf("Cannot open extension file")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate settings if present
|
|
||||||
for i, setting := range m.Settings {
|
for i, setting := range m.Settings {
|
||||||
if strings.TrimSpace(setting.Key) == "" {
|
if strings.TrimSpace(setting.Key) == "" {
|
||||||
return &ManifestValidationError{
|
return &ManifestValidationError{
|
||||||
@@ -236,7 +235,7 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
|||||||
}
|
}
|
||||||
// Support wildcard subdomains (e.g., *.example.com)
|
// Support wildcard subdomains (e.g., *.example.com)
|
||||||
if strings.HasPrefix(allowed, "*.") {
|
if strings.HasPrefix(allowed, "*.") {
|
||||||
suffix := allowed[1:] // Remove the *
|
suffix := allowed[1:]
|
||||||
if strings.HasSuffix(domain, suffix) {
|
if strings.HasSuffix(domain, suffix) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -269,7 +268,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
|||||||
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||||
for _, pattern := range m.URLHandler.Patterns {
|
for _, pattern := range m.URLHandler.Patterns {
|
||||||
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||||
// Check if URL contains the pattern (host match)
|
|
||||||
if strings.Contains(urlStr, pattern) {
|
if strings.Contains(urlStr, pattern) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
|||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Jar: jar,
|
Jar: jar,
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
// Validate redirect target domain against allowed domains
|
|
||||||
if req.URL.Scheme != "https" {
|
if req.URL.Scheme != "https" {
|
||||||
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
||||||
return fmt.Errorf("redirect blocked: only https is allowed")
|
return fmt.Errorf("redirect blocked: only https is allowed")
|
||||||
@@ -125,7 +124,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
|||||||
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
||||||
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
||||||
}
|
}
|
||||||
// Default redirect limit (10)
|
|
||||||
if len(via) >= 10 {
|
if len(via) >= 10 {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
}
|
}
|
||||||
@@ -227,7 +225,6 @@ func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
|||||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||||
r.vm = vm
|
r.vm = vm
|
||||||
|
|
||||||
// HTTP client (sandboxed to allowed domains)
|
|
||||||
httpObj := vm.NewObject()
|
httpObj := vm.NewObject()
|
||||||
httpObj.Set("get", r.httpGet)
|
httpObj.Set("get", r.httpGet)
|
||||||
httpObj.Set("post", r.httpPost)
|
httpObj.Set("post", r.httpPost)
|
||||||
@@ -264,7 +261,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||||
vm.Set("auth", authObj)
|
vm.Set("auth", authObj)
|
||||||
|
|
||||||
// File operations (sandboxed)
|
|
||||||
fileObj := vm.NewObject()
|
fileObj := vm.NewObject()
|
||||||
fileObj.Set("download", r.fileDownload)
|
fileObj.Set("download", r.fileDownload)
|
||||||
fileObj.Set("exists", r.fileExists)
|
fileObj.Set("exists", r.fileExists)
|
||||||
@@ -282,7 +278,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
ffmpegObj.Set("convert", r.ffmpegConvert)
|
ffmpegObj.Set("convert", r.ffmpegConvert)
|
||||||
vm.Set("ffmpeg", ffmpegObj)
|
vm.Set("ffmpeg", ffmpegObj)
|
||||||
|
|
||||||
// Track matching API
|
|
||||||
matchingObj := vm.NewObject()
|
matchingObj := vm.NewObject()
|
||||||
matchingObj.Set("compareStrings", r.matchingCompareStrings)
|
matchingObj.Set("compareStrings", r.matchingCompareStrings)
|
||||||
matchingObj.Set("compareDuration", r.matchingCompareDuration)
|
matchingObj.Set("compareDuration", r.matchingCompareDuration)
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// authIsAuthenticated checks if extension has valid auth
|
|
||||||
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
@@ -194,7 +193,6 @@ func generatePKCEChallenge(verifier string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||||
// Default length is 64 characters
|
|
||||||
length := 64
|
length := 64
|
||||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||||
@@ -247,9 +245,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// authStartOAuthWithPKCE is a high-level helper that generates PKCE and opens OAuth URL
|
|
||||||
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
||||||
// Returns: { success, authUrl, pkce: { verifier, challenge } }
|
|
||||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -267,7 +263,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Required fields
|
|
||||||
authURL, _ := config["authUrl"].(string)
|
authURL, _ := config["authUrl"].(string)
|
||||||
clientID, _ := config["clientId"].(string)
|
clientID, _ := config["clientId"].(string)
|
||||||
redirectURI, _ := config["redirectUri"].(string)
|
redirectURI, _ := config["redirectUri"].(string)
|
||||||
@@ -279,11 +274,9 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional fields
|
|
||||||
scope, _ := config["scope"].(string)
|
scope, _ := config["scope"].(string)
|
||||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||||
|
|
||||||
// Generate PKCE
|
|
||||||
verifier, err := generatePKCEVerifier(64)
|
verifier, err := generatePKCEVerifier(64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -293,7 +286,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
}
|
}
|
||||||
challenge := generatePKCEChallenge(verifier)
|
challenge := generatePKCEChallenge(verifier)
|
||||||
|
|
||||||
// Store PKCE in auth state
|
|
||||||
extensionAuthStateMu.Lock()
|
extensionAuthStateMu.Lock()
|
||||||
state, exists := extensionAuthState[r.extensionID]
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -302,10 +294,9 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
}
|
}
|
||||||
state.PKCEVerifier = verifier
|
state.PKCEVerifier = verifier
|
||||||
state.PKCEChallenge = challenge
|
state.PKCEChallenge = challenge
|
||||||
state.AuthCode = "" // Clear any previous auth code
|
state.AuthCode = ""
|
||||||
extensionAuthStateMu.Unlock()
|
extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
// Build OAuth URL with PKCE parameters
|
|
||||||
parsedURL, err := url.Parse(authURL)
|
parsedURL, err := url.Parse(authURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -325,7 +316,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
query.Set("scope", scope)
|
query.Set("scope", scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add extra params
|
|
||||||
for k, v := range extraParams {
|
for k, v := range extraParams {
|
||||||
query.Set(k, fmt.Sprintf("%v", v))
|
query.Set(k, fmt.Sprintf("%v", v))
|
||||||
}
|
}
|
||||||
@@ -333,7 +323,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
parsedURL.RawQuery = query.Encode()
|
parsedURL.RawQuery = query.Encode()
|
||||||
fullAuthURL := parsedURL.String()
|
fullAuthURL := parsedURL.String()
|
||||||
|
|
||||||
// Store pending auth request for Flutter
|
|
||||||
pendingAuthRequestsMu.Lock()
|
pendingAuthRequestsMu.Lock()
|
||||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||||
ExtensionID: r.extensionID,
|
ExtensionID: r.extensionID,
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ func isPathWithinBase(baseDir, targetPath string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||||
// Check if extension has file permission
|
|
||||||
if !r.manifest.Permissions.File {
|
if !r.manifest.Permissions.File {
|
||||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
|||||||
return fmt.Errorf("invalid URL: hostname is required")
|
return fmt.Errorf("invalid URL: hostname is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block private/local network access (SSRF protection)
|
|
||||||
if isPrivateIP(domain) {
|
if isPrivateIP(domain) {
|
||||||
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
|
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
|
|
||||||
// ==================== Track Matching API ====================
|
// ==================== Track Matching API ====================
|
||||||
|
|
||||||
// matchingCompareStrings compares two strings with fuzzy matching
|
|
||||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(0.0)
|
return r.vm.ToValue(0.0)
|
||||||
@@ -22,12 +21,10 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
|
|||||||
return r.vm.ToValue(1.0)
|
return r.vm.ToValue(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate Levenshtein distance-based similarity
|
|
||||||
similarity := calculateStringSimilarity(str1, str2)
|
similarity := calculateStringSimilarity(str1, str2)
|
||||||
return r.vm.ToValue(similarity)
|
return r.vm.ToValue(similarity)
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchingCompareDuration compares two durations with tolerance
|
|
||||||
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
@@ -36,8 +33,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
|||||||
dur1 := int(call.Arguments[0].ToInteger())
|
dur1 := int(call.Arguments[0].ToInteger())
|
||||||
dur2 := int(call.Arguments[1].ToInteger())
|
dur2 := int(call.Arguments[1].ToInteger())
|
||||||
|
|
||||||
// Default tolerance: 3 seconds
|
tolerance := 3000
|
||||||
tolerance := 3000 // milliseconds
|
|
||||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
|
||||||
tolerance = int(call.Arguments[2].ToInteger())
|
tolerance = int(call.Arguments[2].ToInteger())
|
||||||
}
|
}
|
||||||
@@ -50,7 +46,6 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
|||||||
return r.vm.ToValue(diff <= tolerance)
|
return r.vm.ToValue(diff <= tolerance)
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchingNormalizeString normalizes a string for comparison
|
|
||||||
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
@@ -61,7 +56,6 @@ func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.
|
|||||||
return r.vm.ToValue(normalized)
|
return r.vm.ToValue(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateStringSimilarity calculates similarity between two strings (0-1)
|
|
||||||
func calculateStringSimilarity(s1, s2 string) float64 {
|
func calculateStringSimilarity(s1, s2 string) float64 {
|
||||||
if len(s1) == 0 && len(s2) == 0 {
|
if len(s1) == 0 && len(s2) == 0 {
|
||||||
return 1.0
|
return 1.0
|
||||||
@@ -70,7 +64,6 @@ func calculateStringSimilarity(s1, s2 string) float64 {
|
|||||||
return 0.0
|
return 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Levenshtein distance
|
|
||||||
distance := levenshteinDistance(s1, s2)
|
distance := levenshteinDistance(s1, s2)
|
||||||
maxLen := len(s1)
|
maxLen := len(s1)
|
||||||
if len(s2) > maxLen {
|
if len(s2) > maxLen {
|
||||||
@@ -80,7 +73,6 @@ func calculateStringSimilarity(s1, s2 string) float64 {
|
|||||||
return 1.0 - float64(distance)/float64(maxLen)
|
return 1.0 - float64(distance)/float64(maxLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// levenshteinDistance calculates the Levenshtein distance between two strings
|
|
||||||
func levenshteinDistance(s1, s2 string) int {
|
func levenshteinDistance(s1, s2 string) int {
|
||||||
if len(s1) == 0 {
|
if len(s1) == 0 {
|
||||||
return len(s2)
|
return len(s2)
|
||||||
@@ -89,7 +81,6 @@ func levenshteinDistance(s1, s2 string) int {
|
|||||||
return len(s1)
|
return len(s1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create matrix
|
|
||||||
matrix := make([][]int, len(s1)+1)
|
matrix := make([][]int, len(s1)+1)
|
||||||
for i := range matrix {
|
for i := range matrix {
|
||||||
matrix[i] = make([]int, len(s2)+1)
|
matrix[i] = make([]int, len(s2)+1)
|
||||||
@@ -99,7 +90,6 @@ func levenshteinDistance(s1, s2 string) int {
|
|||||||
matrix[0][j] = j
|
matrix[0][j] = j
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill matrix
|
|
||||||
for i := 1; i <= len(s1); i++ {
|
for i := 1; i <= len(s1); i++ {
|
||||||
for j := 1; j <= len(s2); j++ {
|
for j := 1; j <= len(s2); j++ {
|
||||||
cost := 1
|
cost := 1
|
||||||
@@ -107,9 +97,9 @@ func levenshteinDistance(s1, s2 string) int {
|
|||||||
cost = 0
|
cost = 0
|
||||||
}
|
}
|
||||||
matrix[i][j] = min(
|
matrix[i][j] = min(
|
||||||
matrix[i-1][j]+1, // deletion
|
matrix[i-1][j]+1,
|
||||||
matrix[i][j-1]+1, // insertion
|
matrix[i][j-1]+1,
|
||||||
matrix[i-1][j-1]+cost, // substitution
|
matrix[i-1][j-1]+cost,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,12 +107,9 @@ func levenshteinDistance(s1, s2 string) int {
|
|||||||
return matrix[len(s1)][len(s2)]
|
return matrix[len(s1)][len(s2)]
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeStringForMatching normalizes a string for comparison
|
|
||||||
func normalizeStringForMatching(s string) string {
|
func normalizeStringForMatching(s string) string {
|
||||||
// Convert to lowercase
|
|
||||||
s = strings.ToLower(s)
|
s = strings.ToLower(s)
|
||||||
|
|
||||||
// Remove common suffixes/prefixes
|
|
||||||
suffixes := []string{
|
suffixes := []string{
|
||||||
" (remastered)", " (remaster)", " - remastered", " - remaster",
|
" (remastered)", " (remaster)", " - remastered", " - remaster",
|
||||||
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
|
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
|
||||||
@@ -136,7 +123,6 @@ func normalizeStringForMatching(s string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove special characters
|
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
|
||||||
@@ -144,7 +130,6 @@ func normalizeStringForMatching(s string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapse multiple spaces
|
|
||||||
s = strings.Join(strings.Fields(result.String()), " ")
|
s = strings.Join(strings.Fields(result.String()), " ")
|
||||||
|
|
||||||
return strings.TrimSpace(s)
|
return strings.TrimSpace(s)
|
||||||
|
|||||||
@@ -25,14 +25,11 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
urlStr := call.Arguments[0].String()
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
// Validate domain
|
|
||||||
if err := r.validateDomain(urlStr); err != nil {
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
|
||||||
return r.createFetchError(err.Error())
|
return r.createFetchError(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse options
|
|
||||||
method := "GET"
|
method := "GET"
|
||||||
var bodyStr string
|
var bodyStr string
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
@@ -84,7 +81,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
// Set defaults if not provided
|
|
||||||
if req.Header.Get("User-Agent") == "" {
|
if req.Header.Get("User-Agent") == "" {
|
||||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||||
}
|
}
|
||||||
@@ -112,7 +108,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Response object (browser-compatible)
|
|
||||||
responseObj := r.vm.NewObject()
|
responseObj := r.vm.NewObject()
|
||||||
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
||||||
responseObj.Set("status", resp.StatusCode)
|
responseObj.Set("status", resp.StatusCode)
|
||||||
@@ -136,7 +131,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
|
|
||||||
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
|
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
|
||||||
// Return as array of bytes
|
|
||||||
byteArray := make([]interface{}, len(body))
|
byteArray := make([]interface{}, len(body))
|
||||||
for i, b := range body {
|
for i, b := range body {
|
||||||
byteArray[i] = int(b)
|
byteArray[i] = int(b)
|
||||||
@@ -171,7 +165,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
input := call.Arguments[0].String()
|
input := call.Arguments[0].String()
|
||||||
decoded, err := base64.StdEncoding.DecodeString(input)
|
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Try URL-safe base64
|
|
||||||
decoded, err = base64.URLEncoding.DecodeString(input)
|
decoded, err = base64.URLEncoding.DecodeString(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
|
||||||
@@ -192,7 +185,6 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
|
|
||||||
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
||||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||||
// TextEncoder constructor
|
|
||||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||||
encoder := call.This
|
encoder := call.This
|
||||||
encoder.Set("encoding", "utf-8")
|
encoder.Set("encoding", "utf-8")
|
||||||
@@ -204,7 +196,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
input := call.Arguments[0].String()
|
input := call.Arguments[0].String()
|
||||||
bytes := []byte(input)
|
bytes := []byte(input)
|
||||||
|
|
||||||
// Return as array (Uint8Array-like)
|
|
||||||
result := make([]interface{}, len(bytes))
|
result := make([]interface{}, len(bytes))
|
||||||
for i, b := range bytes {
|
for i, b := range bytes {
|
||||||
result[i] = int(b)
|
result[i] = int(b)
|
||||||
@@ -227,11 +218,9 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// TextDecoder constructor
|
|
||||||
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
|
||||||
decoder := call.This
|
decoder := call.This
|
||||||
|
|
||||||
// Get encoding from arguments (default: utf-8)
|
|
||||||
encoding := "utf-8"
|
encoding := "utf-8"
|
||||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
encoding = call.Arguments[0].String()
|
encoding = call.Arguments[0].String()
|
||||||
@@ -245,7 +234,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
return vm.ToValue("")
|
return vm.ToValue("")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle different input types
|
|
||||||
input := call.Arguments[0].Export()
|
input := call.Arguments[0].Export()
|
||||||
var bytes []byte
|
var bytes []byte
|
||||||
|
|
||||||
@@ -265,7 +253,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case string:
|
case string:
|
||||||
// Already a string, just return it
|
|
||||||
return vm.ToValue(v)
|
return vm.ToValue(v)
|
||||||
default:
|
default:
|
||||||
return vm.ToValue("")
|
return vm.ToValue("")
|
||||||
@@ -289,7 +276,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
|||||||
|
|
||||||
urlStr := call.Arguments[0].String()
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
// Handle relative URLs with base
|
|
||||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||||
baseStr := call.Arguments[1].String()
|
baseStr := call.Arguments[1].String()
|
||||||
baseURL, err := url.Parse(baseStr)
|
baseURL, err := url.Parse(baseStr)
|
||||||
@@ -446,10 +432,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
|||||||
// registerJSONGlobal ensures JSON global is properly set up
|
// registerJSONGlobal ensures JSON global is properly set up
|
||||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||||
// JSON is already built-in to Goja, but we can enhance it
|
// JSON is already built-in to Goja, but we can enhance it
|
||||||
// This ensures JSON.parse and JSON.stringify work as expected
|
|
||||||
|
|
||||||
// The built-in JSON object should already work, but let's verify
|
|
||||||
// and add any missing functionality if needed
|
|
||||||
jsonScript := `
|
jsonScript := `
|
||||||
if (typeof JSON === 'undefined') {
|
if (typeof JSON === 'undefined') {
|
||||||
var JSON = {
|
var JSON = {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ type ExtensionSettingsStore struct {
|
|||||||
settings map[string]map[string]interface{} // extensionID -> settings
|
settings map[string]map[string]interface{} // extensionID -> settings
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global settings store
|
|
||||||
var (
|
var (
|
||||||
globalSettingsStore *ExtensionSettingsStore
|
globalSettingsStore *ExtensionSettingsStore
|
||||||
globalSettingsStoreOnce sync.Once
|
globalSettingsStoreOnce sync.Once
|
||||||
@@ -129,7 +128,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
|
|||||||
return make(map[string]interface{})
|
return make(map[string]interface{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return a copy
|
|
||||||
result := make(map[string]interface{})
|
result := make(map[string]interface{})
|
||||||
for k, v := range extSettings {
|
for k, v := range extSettings {
|
||||||
result[k] = v
|
result[k] = v
|
||||||
@@ -156,7 +154,6 @@ func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]
|
|||||||
|
|
||||||
s.settings[extensionID] = settings
|
s.settings[extensionID] = settings
|
||||||
|
|
||||||
// Persist to disk
|
|
||||||
return s.saveSettings(extensionID, settings)
|
return s.saveSettings(extensionID, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +168,6 @@ func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
|||||||
|
|
||||||
delete(extSettings, key)
|
delete(extSettings, key)
|
||||||
|
|
||||||
// Persist to disk
|
|
||||||
return s.saveSettings(extensionID, extSettings)
|
return s.saveSettings(extensionID, extSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Extension categories
|
|
||||||
const (
|
const (
|
||||||
CategoryMetadata = "metadata"
|
CategoryMetadata = "metadata"
|
||||||
CategoryDownload = "download"
|
CategoryDownload = "download"
|
||||||
@@ -146,7 +145,6 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
|
|||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
cacheTTL: cacheTTL,
|
cacheTTL: cacheTTL,
|
||||||
}
|
}
|
||||||
// Try to load from disk cache
|
|
||||||
extensionStore.loadDiskCache()
|
extensionStore.loadDiskCache()
|
||||||
}
|
}
|
||||||
return extensionStore
|
return extensionStore
|
||||||
@@ -209,7 +207,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
// Return cached if valid and not forcing refresh
|
|
||||||
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
||||||
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||||
return s.cache, nil
|
return s.cache, nil
|
||||||
@@ -224,7 +221,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
resp, err := client.Get(s.registryURL)
|
resp, err := client.Get(s.registryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Return cached data if available on network error
|
|
||||||
if s.cache != nil {
|
if s.cache != nil {
|
||||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||||
return s.cache, nil
|
return s.cache, nil
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
|||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
// Test allowed domains
|
|
||||||
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||||
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -121,7 +120,6 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
|||||||
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
|
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test blocked domains
|
|
||||||
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
|
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
|
||||||
t.Error("Expected blocked.com to be denied")
|
t.Error("Expected blocked.com to be denied")
|
||||||
}
|
}
|
||||||
@@ -139,7 +137,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
Permissions: ExtensionPermissions{
|
Permissions: ExtensionPermissions{
|
||||||
File: true, // Enable file permission for test
|
File: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
DataDir: tempDir,
|
DataDir: tempDir,
|
||||||
@@ -147,7 +145,6 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
// Test valid path within sandbox
|
|
||||||
validPath, err := runtime.validatePath("test.txt")
|
validPath, err := runtime.validatePath("test.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Expected relative path to be valid, got error: %v", err)
|
t.Errorf("Expected relative path to be valid, got error: %v", err)
|
||||||
@@ -156,13 +153,11 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
t.Error("Expected non-empty path")
|
t.Error("Expected non-empty path")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test path traversal attack
|
|
||||||
_, err = runtime.validatePath("../../../etc/passwd")
|
_, err = runtime.validatePath("../../../etc/passwd")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Expected path traversal to be blocked")
|
t.Error("Expected path traversal to be blocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test nested path within sandbox (should be allowed)
|
|
||||||
nestedPath, err := runtime.validatePath("subdir/file.txt")
|
nestedPath, err := runtime.validatePath("subdir/file.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Expected nested path to be valid, got error: %v", err)
|
t.Errorf("Expected nested path to be valid, got error: %v", err)
|
||||||
@@ -171,26 +166,23 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
t.Error("Expected non-empty nested path")
|
t.Error("Expected non-empty nested path")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test absolute path should be blocked (security fix)
|
|
||||||
// Use platform-appropriate absolute path
|
|
||||||
var absPath string
|
var absPath string
|
||||||
if filepath.IsAbs("C:\\Windows\\System32") {
|
if filepath.IsAbs("C:\\Windows\\System32") {
|
||||||
absPath = "C:\\Windows\\System32\\test.txt" // Windows
|
absPath = "C:\\Windows\\System32\\test.txt"
|
||||||
} else {
|
} else {
|
||||||
absPath = "/etc/passwd" // Unix
|
absPath = "/etc/passwd"
|
||||||
}
|
}
|
||||||
_, err = runtime.validatePath(absPath)
|
_, err = runtime.validatePath(absPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Expected absolute path to be blocked")
|
t.Error("Expected absolute path to be blocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that extension without file permission is blocked
|
|
||||||
extNoFile := &LoadedExtension{
|
extNoFile := &LoadedExtension{
|
||||||
ID: "test-ext-no-file",
|
ID: "test-ext-no-file",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext-no-file",
|
Name: "test-ext-no-file",
|
||||||
Permissions: ExtensionPermissions{
|
Permissions: ExtensionPermissions{
|
||||||
File: false, // No file permission
|
File: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
DataDir: tempDir,
|
DataDir: tempDir,
|
||||||
@@ -215,7 +207,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
runtime.RegisterAPIs(vm)
|
runtime.RegisterAPIs(vm)
|
||||||
|
|
||||||
// Test base64 encode/decode
|
|
||||||
result, err := vm.RunString(`utils.base64Encode("hello")`)
|
result, err := vm.RunString(`utils.base64Encode("hello")`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("base64Encode failed: %v", err)
|
t.Fatalf("base64Encode failed: %v", err)
|
||||||
@@ -232,7 +223,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
t.Errorf("Expected 'hello', got '%s'", result.String())
|
t.Errorf("Expected 'hello', got '%s'", result.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test MD5
|
|
||||||
result, err = vm.RunString(`utils.md5("hello")`)
|
result, err = vm.RunString(`utils.md5("hello")`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("md5 failed: %v", err)
|
t.Fatalf("md5 failed: %v", err)
|
||||||
@@ -241,7 +231,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
|
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test JSON parse/stringify
|
|
||||||
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
|
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("stringifyJSON failed: %v", err)
|
t.Fatalf("stringifyJSON failed: %v", err)
|
||||||
@@ -267,7 +256,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
|||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
// Test that private IPs are blocked (SSRF protection)
|
|
||||||
privateIPs := []string{
|
privateIPs := []string{
|
||||||
"http://localhost/admin",
|
"http://localhost/admin",
|
||||||
"http://127.0.0.1/admin",
|
"http://127.0.0.1/admin",
|
||||||
@@ -285,7 +273,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that allowed public domain still works
|
|
||||||
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
||||||
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
|
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -296,7 +283,6 @@ func TestIsPrivateIP(t *testing.T) {
|
|||||||
host string
|
host string
|
||||||
expected bool
|
expected bool
|
||||||
}{
|
}{
|
||||||
// Private IPs should be blocked
|
|
||||||
{"localhost", true},
|
{"localhost", true},
|
||||||
{"127.0.0.1", true},
|
{"127.0.0.1", true},
|
||||||
{"127.0.0.2", true},
|
{"127.0.0.2", true},
|
||||||
@@ -306,18 +292,17 @@ func TestIsPrivateIP(t *testing.T) {
|
|||||||
{"172.31.255.255", true},
|
{"172.31.255.255", true},
|
||||||
{"192.168.0.1", true},
|
{"192.168.0.1", true},
|
||||||
{"192.168.255.255", true},
|
{"192.168.255.255", true},
|
||||||
{"169.254.169.254", true}, // AWS metadata
|
{"169.254.169.254", true},
|
||||||
{"router.local", true},
|
{"router.local", true},
|
||||||
{"mydevice.local", true},
|
{"mydevice.local", true},
|
||||||
|
|
||||||
// Public IPs should be allowed
|
|
||||||
{"8.8.8.8", false},
|
{"8.8.8.8", false},
|
||||||
{"1.1.1.1", false},
|
{"1.1.1.1", false},
|
||||||
{"api.example.com", false},
|
{"api.example.com", false},
|
||||||
{"google.com", false},
|
{"google.com", false},
|
||||||
{"172.15.0.1", false}, // Just outside 172.16-31 range
|
{"172.15.0.1", false},
|
||||||
{"172.32.0.1", false}, // Just outside 172.16-31 range
|
{"172.32.0.1", false},
|
||||||
{"192.167.0.1", false}, // Not 192.168.x.x
|
{"192.167.0.1", false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JSExecutionError represents an error during JS execution
|
|
||||||
type JSExecutionError struct {
|
type JSExecutionError struct {
|
||||||
Message string
|
Message string
|
||||||
IsTimeout bool
|
IsTimeout bool
|
||||||
@@ -20,8 +19,6 @@ func (e *JSExecutionError) Error() string {
|
|||||||
return e.Message
|
return e.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunWithTimeout executes JavaScript code with a timeout
|
|
||||||
// Returns the result value and any error (including timeout)
|
|
||||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = DefaultJSTimeout
|
timeout = DefaultJSTimeout
|
||||||
@@ -30,22 +27,18 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Channel to receive result
|
|
||||||
type result struct {
|
type result struct {
|
||||||
value goja.Value
|
value goja.Value
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
resultCh := make(chan result, 1)
|
resultCh := make(chan result, 1)
|
||||||
|
|
||||||
// Track if we've interrupted
|
|
||||||
var interrupted bool
|
var interrupted bool
|
||||||
var interruptMu sync.Mutex
|
var interruptMu sync.Mutex
|
||||||
|
|
||||||
// Run script in goroutine
|
|
||||||
go func() {
|
go func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
// Check if this was our interrupt
|
|
||||||
interruptMu.Lock()
|
interruptMu.Lock()
|
||||||
wasInterrupted := interrupted
|
wasInterrupted := interrupted
|
||||||
interruptMu.Unlock()
|
interruptMu.Unlock()
|
||||||
@@ -65,22 +58,18 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
resultCh <- result{val, err}
|
resultCh <- result{val, err}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Wait for result or timeout
|
|
||||||
select {
|
select {
|
||||||
case res := <-resultCh:
|
case res := <-resultCh:
|
||||||
return res.value, res.err
|
return res.value, res.err
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// Timeout - interrupt the VM
|
|
||||||
interruptMu.Lock()
|
interruptMu.Lock()
|
||||||
interrupted = true
|
interrupted = true
|
||||||
interruptMu.Unlock()
|
interruptMu.Unlock()
|
||||||
|
|
||||||
vm.Interrupt("execution timeout")
|
vm.Interrupt("execution timeout")
|
||||||
|
|
||||||
// Wait a bit for the goroutine to finish
|
|
||||||
select {
|
select {
|
||||||
case res := <-resultCh:
|
case res := <-resultCh:
|
||||||
// If we got a result after interrupt, it might be the timeout error
|
|
||||||
if res.err != nil {
|
if res.err != nil {
|
||||||
return nil, res.err
|
return nil, res.err
|
||||||
}
|
}
|
||||||
@@ -89,7 +78,6 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
}
|
}
|
||||||
case <-time.After(1 * time.Second):
|
case <-time.After(1 * time.Second):
|
||||||
// Force return timeout error
|
|
||||||
return nil, &JSExecutionError{
|
return nil, &JSExecutionError{
|
||||||
Message: "execution timeout exceeded (force)",
|
Message: "execution timeout exceeded (force)",
|
||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
@@ -109,7 +97,6 @@ func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Dura
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsTimeoutError checks if an error is a timeout error
|
|
||||||
func IsTimeoutError(err error) bool {
|
func IsTimeoutError(err error) bool {
|
||||||
if jsErr, ok := err.(*JSExecutionError); ok {
|
if jsErr, ok := err.(*JSExecutionError); ok {
|
||||||
return jsErr.IsTimeout
|
return jsErr.IsTimeout
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func getRandomUserAgent() string {
|
func getRandomUserAgent() string {
|
||||||
// Chrome version 120-145 (modern range)
|
|
||||||
chromeVersion := rand.Intn(26) + 120
|
chromeVersion := rand.Intn(26) + 120
|
||||||
chromeBuild := rand.Intn(1500) + 6000
|
chromeBuild := rand.Intn(1500) + 6000
|
||||||
chromePatch := rand.Intn(200) + 100
|
chromePatch := rand.Intn(200) + 100
|
||||||
@@ -118,7 +117,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
requestURL := req.URL.String()
|
requestURL := req.URL.String()
|
||||||
|
|
||||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||||
// Clone request for retry (body needs to be re-readable)
|
|
||||||
reqCopy := req.Clone(req.Context())
|
reqCopy := req.Clone(req.Context())
|
||||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
@@ -126,9 +124,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
// Check for ISP blocking on network errors
|
|
||||||
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
||||||
// Don't retry if ISP blocking is detected - it won't help
|
|
||||||
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ func newUTLSTransport() *utlsTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
// For non-HTTPS, use standard transport
|
|
||||||
if req.URL.Scheme != "https" {
|
if req.URL.Scheme != "https" {
|
||||||
return sharedTransport.RoundTrip(req)
|
return sharedTransport.RoundTrip(req)
|
||||||
}
|
}
|
||||||
@@ -44,29 +43,24 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||||||
port := t.getPort(req.URL)
|
port := t.getPort(req.URL)
|
||||||
addr := net.JoinHostPort(host, port)
|
addr := net.JoinHostPort(host, port)
|
||||||
|
|
||||||
// Dial TCP connection
|
|
||||||
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
|
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create uTLS connection with Chrome fingerprint (supports HTTP/2 ALPN)
|
|
||||||
tlsConn := utls.UClient(conn, &utls.Config{
|
tlsConn := utls.UClient(conn, &utls.Config{
|
||||||
ServerName: host,
|
ServerName: host,
|
||||||
NextProtos: []string{"h2", "http/1.1"}, // Prefer HTTP/2
|
NextProtos: []string{"h2", "http/1.1"},
|
||||||
}, utls.HelloChrome_Auto)
|
}, utls.HelloChrome_Auto)
|
||||||
|
|
||||||
// Perform TLS handshake
|
|
||||||
if err := tlsConn.Handshake(); err != nil {
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if server supports HTTP/2
|
|
||||||
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
|
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
|
||||||
|
|
||||||
if negotiatedProto == "h2" {
|
if negotiatedProto == "h2" {
|
||||||
// Use HTTP/2 transport
|
|
||||||
h2Transport := &http2.Transport{
|
h2Transport := &http2.Transport{
|
||||||
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
|
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
|
||||||
return tlsConn, nil
|
return tlsConn, nil
|
||||||
@@ -77,7 +71,6 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||||||
return h2Transport.RoundTrip(req)
|
return h2Transport.RoundTrip(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to HTTP/1.1
|
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
return tlsConn, nil
|
return tlsConn, nil
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ type LibraryScanResult struct {
|
|||||||
Format string `json:"format,omitempty"`
|
Format string `json:"format,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LibraryScanProgress reports progress during scan
|
|
||||||
type LibraryScanProgress struct {
|
type LibraryScanProgress struct {
|
||||||
TotalFiles int `json:"total_files"`
|
TotalFiles int `json:"total_files"`
|
||||||
ScannedFiles int `json:"scanned_files"`
|
ScannedFiles int `json:"scanned_files"`
|
||||||
@@ -50,7 +49,6 @@ var (
|
|||||||
libraryCoverCacheMu sync.RWMutex
|
libraryCoverCacheMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// supportedAudioFormats lists file extensions we can read metadata from
|
|
||||||
var supportedAudioFormats = map[string]bool{
|
var supportedAudioFormats = map[string]bool{
|
||||||
".flac": true,
|
".flac": true,
|
||||||
".m4a": true,
|
".m4a": true,
|
||||||
@@ -59,15 +57,12 @@ var supportedAudioFormats = map[string]bool{
|
|||||||
".ogg": true,
|
".ogg": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLibraryCoverCacheDir sets the directory to cache extracted cover art
|
|
||||||
func SetLibraryCoverCacheDir(cacheDir string) {
|
func SetLibraryCoverCacheDir(cacheDir string) {
|
||||||
libraryCoverCacheMu.Lock()
|
libraryCoverCacheMu.Lock()
|
||||||
libraryCoverCacheDir = cacheDir
|
libraryCoverCacheDir = cacheDir
|
||||||
libraryCoverCacheMu.Unlock()
|
libraryCoverCacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanLibraryFolder scans a folder recursively for audio files and reads their metadata
|
|
||||||
// Returns JSON array of LibraryScanResult
|
|
||||||
func ScanLibraryFolder(folderPath string) (string, error) {
|
func ScanLibraryFolder(folderPath string) (string, error) {
|
||||||
if folderPath == "" {
|
if folderPath == "" {
|
||||||
return "[]", fmt.Errorf("folder path is empty")
|
return "[]", fmt.Errorf("folder path is empty")
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ var (
|
|||||||
logBufferOnce sync.Once
|
logBufferOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetLogBuffer returns the singleton log buffer instance
|
|
||||||
func GetLogBuffer() *LogBuffer {
|
func GetLogBuffer() *LogBuffer {
|
||||||
logBufferOnce.Do(func() {
|
logBufferOnce.Do(func() {
|
||||||
globalLogBuffer = &LogBuffer{
|
globalLogBuffer = &LogBuffer{
|
||||||
@@ -45,7 +44,6 @@ func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
|||||||
lb.loggingEnabled = enabled
|
lb.loggingEnabled = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsLoggingEnabled returns whether logging is enabled
|
|
||||||
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||||
lb.mu.RLock()
|
lb.mu.RLock()
|
||||||
defer lb.mu.RUnlock()
|
defer lb.mu.RUnlock()
|
||||||
@@ -75,7 +73,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
fmt.Printf("[%s] %s\n", tag, message)
|
fmt.Printf("[%s] %s\n", tag, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll returns all log entries as JSON
|
|
||||||
func (lb *LogBuffer) GetAll() string {
|
func (lb *LogBuffer) GetAll() string {
|
||||||
lb.mu.RLock()
|
lb.mu.RLock()
|
||||||
defer lb.mu.RUnlock()
|
defer lb.mu.RUnlock()
|
||||||
@@ -99,21 +96,18 @@ func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
|||||||
return entries, len(lb.entries)
|
return entries, len(lb.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear clears all log entries
|
|
||||||
func (lb *LogBuffer) Clear() {
|
func (lb *LogBuffer) Clear() {
|
||||||
lb.mu.Lock()
|
lb.mu.Lock()
|
||||||
defer lb.mu.Unlock()
|
defer lb.mu.Unlock()
|
||||||
lb.entries = lb.entries[:0]
|
lb.entries = lb.entries[:0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count returns the number of log entries
|
|
||||||
func (lb *LogBuffer) Count() int {
|
func (lb *LogBuffer) Count() int {
|
||||||
lb.mu.RLock()
|
lb.mu.RLock()
|
||||||
defer lb.mu.RUnlock()
|
defer lb.mu.RUnlock()
|
||||||
return len(lb.entries)
|
return len(lb.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for logging with different levels
|
|
||||||
func LogDebug(tag, format string, args ...interface{}) {
|
func LogDebug(tag, format string, args ...interface{}) {
|
||||||
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
@@ -163,15 +157,10 @@ func GoLog(format string, args ...interface{}) {
|
|||||||
GetLogBuffer().Add(level, tag, message)
|
GetLogBuffer().Add(level, tag, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exported functions for Flutter
|
|
||||||
|
|
||||||
// GetLogs returns all logs as JSON array
|
|
||||||
func GetLogs() string {
|
func GetLogs() string {
|
||||||
return GetLogBuffer().GetAll()
|
return GetLogBuffer().GetAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogsSince returns logs since the given index
|
|
||||||
// Returns JSON: {"logs": [...], "next_index": N}
|
|
||||||
func GetLogsSince(index int) string {
|
func GetLogsSince(index int) string {
|
||||||
entries, nextIndex := GetLogBuffer().getSince(index)
|
entries, nextIndex := GetLogBuffer().getSince(index)
|
||||||
logsJson, _ := json.Marshal(entries)
|
logsJson, _ := json.Marshal(entries)
|
||||||
@@ -179,17 +168,14 @@ func GetLogsSince(index int) string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearLogs clears all logs
|
|
||||||
func ClearLogs() {
|
func ClearLogs() {
|
||||||
GetLogBuffer().Clear()
|
GetLogBuffer().Clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogCount returns the number of log entries
|
|
||||||
func GetLogCount() int {
|
func GetLogCount() int {
|
||||||
return GetLogBuffer().Count()
|
return GetLogBuffer().Count()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLoggingEnabled enables or disables logging from Flutter
|
|
||||||
func SetLoggingEnabled(enabled bool) {
|
func SetLoggingEnabled(enabled bool) {
|
||||||
GetLogBuffer().SetLoggingEnabled(enabled)
|
GetLogBuffer().SetLoggingEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,13 +187,13 @@ type ItemProgressWriter struct {
|
|||||||
writer interface{ Write([]byte) (int, error) }
|
writer interface{ Write([]byte) (int, error) }
|
||||||
itemID string
|
itemID string
|
||||||
current int64
|
current int64
|
||||||
lastReported int64 // Track last reported bytes for threshold-based updates
|
lastReported int64
|
||||||
startTime time.Time // Track start time for speed calculation
|
startTime time.Time
|
||||||
lastTime time.Time // Track last update time for speed calculation
|
lastTime time.Time
|
||||||
lastBytes int64 // Track bytes at last speed calculation
|
lastBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
const progressUpdateThreshold = 64 * 1024
|
||||||
|
|
||||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
@@ -934,14 +934,14 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
|||||||
defer c.rngMu.Unlock()
|
defer c.rngMu.Unlock()
|
||||||
|
|
||||||
macMajor := c.rng.Intn(4) + 11
|
macMajor := c.rng.Intn(4) + 11
|
||||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
macMinor := c.rng.Intn(5) + 4
|
||||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
webkitMajor := c.rng.Intn(7) + 530
|
||||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
webkitMinor := c.rng.Intn(7) + 30
|
||||||
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
chromeMajor := c.rng.Intn(25) + 80
|
||||||
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
chromeBuild := c.rng.Intn(1500) + 3000
|
||||||
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
chromePatch := c.rng.Intn(65) + 60
|
||||||
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
safariMajor := c.rng.Intn(7) + 530
|
||||||
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
safariMinor := c.rng.Intn(6) + 30
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||||
|
|||||||
@@ -317,7 +317,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find exact ISRC match
|
|
||||||
for i := range result.Items {
|
for i := range result.Items {
|
||||||
if result.Items[i].ISRC == isrc {
|
if result.Items[i].ISRC == isrc {
|
||||||
return &result.Items[i], nil
|
return &result.Items[i], nil
|
||||||
@@ -341,7 +340,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
// Build search queries - multiple strategies (same as PC version)
|
// Build search queries - multiple strategies (same as PC version)
|
||||||
queries := []string{}
|
queries := []string{}
|
||||||
|
|
||||||
// Strategy 1: Artist + Track name (original)
|
|
||||||
if artistName != "" && trackName != "" {
|
if artistName != "" && trackName != "" {
|
||||||
queries = append(queries, artistName+" "+trackName)
|
queries = append(queries, artistName+" "+trackName)
|
||||||
}
|
}
|
||||||
@@ -584,7 +582,6 @@ type tidalAPIResult struct {
|
|||||||
duration time.Duration
|
duration time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
|
||||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
|||||||
|
|
||||||
final _log = AppLogger('ExploreProvider');
|
final _log = AppLogger('ExploreProvider');
|
||||||
|
|
||||||
/// Represents an item in a Spotify home section
|
|
||||||
class ExploreItem {
|
class ExploreItem {
|
||||||
final String id;
|
final String id;
|
||||||
final String uri;
|
final String uri;
|
||||||
@@ -50,7 +49,6 @@ class ExploreItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a section in Spotify home feed
|
|
||||||
class ExploreSection {
|
class ExploreSection {
|
||||||
final String uri;
|
final String uri;
|
||||||
final String title;
|
final String title;
|
||||||
@@ -79,7 +77,6 @@ class ExploreSection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State for explore/home feed
|
|
||||||
class ExploreState {
|
class ExploreState {
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final String? error;
|
final String? error;
|
||||||
@@ -114,7 +111,6 @@ class ExploreState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate greeting based on local device time
|
|
||||||
String _getLocalGreeting() {
|
String _getLocalGreeting() {
|
||||||
final hour = DateTime.now().hour;
|
final hour = DateTime.now().hour;
|
||||||
if (hour >= 5 && hour < 12) {
|
if (hour >= 5 && hour < 12) {
|
||||||
@@ -139,7 +135,6 @@ bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider for explore/home feed state
|
|
||||||
class ExploreNotifier extends Notifier<ExploreState> {
|
class ExploreNotifier extends Notifier<ExploreState> {
|
||||||
@override
|
@override
|
||||||
ExploreState build() {
|
ExploreState build() {
|
||||||
@@ -150,7 +145,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||||
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
||||||
|
|
||||||
// Don't refetch if we have data and it's less than 5 minutes old
|
|
||||||
if (!forceRefresh &&
|
if (!forceRefresh &&
|
||||||
state.hasContent &&
|
state.hasContent &&
|
||||||
state.lastFetched != null &&
|
state.lastFetched != null &&
|
||||||
@@ -167,11 +161,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
state = state.copyWith(isLoading: true, error: null);
|
state = state.copyWith(isLoading: true, error: null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find any extension with homeFeed capability
|
|
||||||
final extState = ref.read(extensionProvider);
|
final extState = ref.read(extensionProvider);
|
||||||
_log.d('Extensions count: ${extState.extensions.length}');
|
_log.d('Extensions count: ${extState.extensions.length}');
|
||||||
|
|
||||||
// Look for extensions with homeFeed capability (prefer spotify-web)
|
|
||||||
Extension? targetExt;
|
Extension? targetExt;
|
||||||
for (final extension in extState.extensions) {
|
for (final extension in extState.extensions) {
|
||||||
if (!extension.enabled || !extension.hasHomeFeed) {
|
if (!extension.enabled || !extension.hasHomeFeed) {
|
||||||
@@ -225,14 +217,11 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
|
|
||||||
_log.i('Fetched ${sections.length} sections');
|
_log.i('Fetched ${sections.length} sections');
|
||||||
|
|
||||||
// Debug: log first section items
|
|
||||||
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
|
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
|
||||||
final firstItem = sections.first.items.first;
|
final firstItem = sections.first.items.first;
|
||||||
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
|
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always use local device time for greeting to avoid timezone issues
|
|
||||||
// Extension greeting may use wrong timezone (UTC or Spotify account timezone)
|
|
||||||
final localGreeting = _getLocalGreeting();
|
final localGreeting = _getLocalGreeting();
|
||||||
_log.d('Greeting from extension: $greeting, using local: $localGreeting');
|
_log.d('Greeting from extension: $greeting, using local: $localGreeting');
|
||||||
|
|
||||||
@@ -251,15 +240,14 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear cached data
|
|
||||||
void clear() {
|
void clear() {
|
||||||
state = const ExploreState();
|
state = const ExploreState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh home feed
|
|
||||||
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
|
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
|
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
|
||||||
return ExploreNotifier();
|
return ExploreNotifier();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -452,7 +452,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
return const ExtensionState();
|
return const ExtensionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the extension system
|
|
||||||
Future<void> initialize(String extensionsDir, String dataDir) async {
|
Future<void> initialize(String extensionsDir, String dataDir) async {
|
||||||
if (state.isInitialized) return;
|
if (state.isInitialized) return;
|
||||||
|
|
||||||
@@ -485,7 +484,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh the list of installed extensions
|
|
||||||
Future<void> refreshExtensions() async {
|
Future<void> refreshExtensions() async {
|
||||||
try {
|
try {
|
||||||
final list = await PlatformBridge.getInstalledExtensions();
|
final list = await PlatformBridge.getInstalledExtensions();
|
||||||
@@ -493,7 +491,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
state = state.copyWith(extensions: extensions);
|
state = state.copyWith(extensions: extensions);
|
||||||
_log.d('Loaded ${extensions.length} extensions');
|
_log.d('Loaded ${extensions.length} extensions');
|
||||||
|
|
||||||
// Log search behavior for extensions that have it
|
|
||||||
for (final ext in extensions) {
|
for (final ext in extensions) {
|
||||||
if (ext.searchBehavior != null) {
|
if (ext.searchBehavior != null) {
|
||||||
_log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}');
|
_log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}');
|
||||||
@@ -505,6 +502,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void clearError() {
|
void clearError() {
|
||||||
state = state.copyWith(error: null);
|
state = state.copyWith(error: null);
|
||||||
}
|
}
|
||||||
@@ -550,7 +548,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Uninstall/remove an extension
|
|
||||||
Future<bool> removeExtension(String extensionId) async {
|
Future<bool> removeExtension(String extensionId) async {
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
state = state.copyWith(isLoading: true, error: null);
|
||||||
|
|
||||||
@@ -567,6 +564,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||||
@@ -603,7 +601,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get settings for an extension
|
|
||||||
Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
|
Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
|
||||||
try {
|
try {
|
||||||
return await PlatformBridge.getExtensionSettings(extensionId);
|
return await PlatformBridge.getExtensionSettings(extensionId);
|
||||||
@@ -623,7 +620,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load provider priority order
|
|
||||||
Future<void> loadProviderPriority() async {
|
Future<void> loadProviderPriority() async {
|
||||||
try {
|
try {
|
||||||
final priority = await PlatformBridge.getProviderPriority();
|
final priority = await PlatformBridge.getProviderPriority();
|
||||||
@@ -633,6 +629,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> setProviderPriority(List<String> priority) async {
|
Future<void> setProviderPriority(List<String> priority) async {
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.setProviderPriority(priority);
|
await PlatformBridge.setProviderPriority(priority);
|
||||||
@@ -644,7 +641,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load metadata provider priority order
|
|
||||||
Future<void> loadMetadataProviderPriority() async {
|
Future<void> loadMetadataProviderPriority() async {
|
||||||
try {
|
try {
|
||||||
final priority = await PlatformBridge.getMetadataProviderPriority();
|
final priority = await PlatformBridge.getMetadataProviderPriority();
|
||||||
@@ -665,7 +661,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cleanup all extensions (call on app close)
|
|
||||||
Future<void> cleanup() async {
|
Future<void> cleanup() async {
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.cleanupExtensions();
|
await PlatformBridge.cleanupExtensions();
|
||||||
@@ -683,7 +678,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all enabled extensions
|
|
||||||
List<Extension> get enabledExtensions {
|
List<Extension> get enabledExtensions {
|
||||||
return state.extensions.where((ext) => ext.enabled).toList();
|
return state.extensions.where((ext) => ext.enabled).toList();
|
||||||
}
|
}
|
||||||
@@ -698,7 +692,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
return providers;
|
return providers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all metadata providers (built-in + extensions)
|
|
||||||
List<String> getAllMetadataProviders() {
|
List<String> getAllMetadataProviders() {
|
||||||
final providers = ['deezer', 'spotify'];
|
final providers = ['deezer', 'spotify'];
|
||||||
for (final ext in state.extensions) {
|
for (final ext in state.extensions) {
|
||||||
@@ -708,6 +701,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
return providers;
|
return providers;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Extension> get searchProviders {
|
List<Extension> get searchProviders {
|
||||||
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
|
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
|||||||
final _log = AppLogger('StoreProvider');
|
final _log = AppLogger('StoreProvider');
|
||||||
final RegExp _leadingVersionPrefix = RegExp(r'^v');
|
final RegExp _leadingVersionPrefix = RegExp(r'^v');
|
||||||
|
|
||||||
/// Compare two semantic version strings
|
|
||||||
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
|
||||||
int compareVersions(String v1, String v2) {
|
int compareVersions(String v1, String v2) {
|
||||||
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
|
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||||
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
|
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||||
@@ -25,8 +23,8 @@ int compareVersions(String v1, String v2) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extension categories
|
|
||||||
class StoreCategory {
|
class StoreCategory {
|
||||||
|
|
||||||
static const String metadata = 'metadata';
|
static const String metadata = 'metadata';
|
||||||
static const String download = 'download';
|
static const String download = 'download';
|
||||||
static const String utility = 'utility';
|
static const String utility = 'utility';
|
||||||
@@ -111,13 +109,13 @@ class StoreExtension {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if this extension requires a higher app version than current
|
|
||||||
bool get requiresNewerApp {
|
bool get requiresNewerApp {
|
||||||
if (minAppVersion == null || minAppVersion!.isEmpty) return false;
|
if (minAppVersion == null || minAppVersion!.isEmpty) return false;
|
||||||
return compareVersions(minAppVersion!, AppInfo.version) > 0;
|
return compareVersions(minAppVersion!, AppInfo.version) > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class StoreState {
|
class StoreState {
|
||||||
final List<StoreExtension> extensions;
|
final List<StoreExtension> extensions;
|
||||||
final String? selectedCategory;
|
final String? selectedCategory;
|
||||||
@@ -164,7 +162,6 @@ class StoreState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get filtered extensions based on category and search
|
|
||||||
List<StoreExtension> get filteredExtensions {
|
List<StoreExtension> get filteredExtensions {
|
||||||
var result = extensions;
|
var result = extensions;
|
||||||
|
|
||||||
@@ -186,13 +183,11 @@ class StoreState {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count of extensions with updates available
|
|
||||||
int get updatesAvailableCount {
|
int get updatesAvailableCount {
|
||||||
return extensions.where((e) => e.hasUpdate).length;
|
return extensions.where((e) => e.hasUpdate).length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider for managing extension store
|
|
||||||
class StoreNotifier extends Notifier<StoreState> {
|
class StoreNotifier extends Notifier<StoreState> {
|
||||||
@override
|
@override
|
||||||
StoreState build() {
|
StoreState build() {
|
||||||
@@ -215,7 +210,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh extensions from store
|
|
||||||
Future<void> refresh({bool forceRefresh = false}) async {
|
Future<void> refresh({bool forceRefresh = false}) async {
|
||||||
state = state.copyWith(isLoading: true, clearError: true);
|
state = state.copyWith(isLoading: true, clearError: true);
|
||||||
|
|
||||||
@@ -240,7 +234,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set search query
|
|
||||||
void setSearchQuery(String query) {
|
void setSearchQuery(String query) {
|
||||||
state = state.copyWith(searchQuery: query);
|
state = state.copyWith(searchQuery: query);
|
||||||
}
|
}
|
||||||
@@ -249,7 +242,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
state = state.copyWith(searchQuery: '', clearCategory: true);
|
state = state.copyWith(searchQuery: '', clearCategory: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download and install extension
|
|
||||||
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
|
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
|
||||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||||
|
|
||||||
@@ -275,6 +267,7 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||||
|
|
||||||
|
|||||||
@@ -3,23 +3,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotiflac_android/models/theme_settings.dart';
|
import 'package:spotiflac_android/models/theme_settings.dart';
|
||||||
|
|
||||||
/// Provider for theme settings state management
|
|
||||||
final themeProvider = NotifierProvider<ThemeNotifier, ThemeSettings>(() {
|
final themeProvider = NotifierProvider<ThemeNotifier, ThemeSettings>(() {
|
||||||
return ThemeNotifier();
|
return ThemeNotifier();
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Notifier for managing theme settings with persistence
|
|
||||||
class ThemeNotifier extends Notifier<ThemeSettings> {
|
class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ThemeSettings build() {
|
ThemeSettings build() {
|
||||||
// Load settings asynchronously on first access
|
|
||||||
_loadFromStorage();
|
_loadFromStorage();
|
||||||
return const ThemeSettings();
|
return const ThemeSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load theme settings from SharedPreferences
|
|
||||||
Future<void> _loadFromStorage() async {
|
Future<void> _loadFromStorage() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await _prefs;
|
final prefs = await _prefs;
|
||||||
@@ -39,7 +35,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save current settings to SharedPreferences
|
|
||||||
Future<void> _saveToStorage() async {
|
Future<void> _saveToStorage() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await _prefs;
|
final prefs = await _prefs;
|
||||||
@@ -52,13 +47,11 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set theme mode (light, dark, or system)
|
|
||||||
Future<void> setThemeMode(ThemeMode mode) async {
|
Future<void> setThemeMode(ThemeMode mode) async {
|
||||||
state = state.copyWith(themeMode: mode);
|
state = state.copyWith(themeMode: mode);
|
||||||
await _saveToStorage();
|
await _saveToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable or disable dynamic color from wallpaper
|
|
||||||
Future<void> setUseDynamicColor(bool value) async {
|
Future<void> setUseDynamicColor(bool value) async {
|
||||||
state = state.copyWith(useDynamicColor: value);
|
state = state.copyWith(useDynamicColor: value);
|
||||||
await _saveToStorage();
|
await _saveToStorage();
|
||||||
@@ -70,19 +63,16 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
await _saveToStorage();
|
await _saveToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set seed color from int value
|
|
||||||
Future<void> setSeedColorValue(int colorValue) async {
|
Future<void> setSeedColorValue(int colorValue) async {
|
||||||
state = state.copyWith(seedColorValue: colorValue);
|
state = state.copyWith(seedColorValue: colorValue);
|
||||||
await _saveToStorage();
|
await _saveToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable or disable AMOLED mode (pure black background)
|
|
||||||
Future<void> setUseAmoled(bool value) async {
|
Future<void> setUseAmoled(bool value) async {
|
||||||
state = state.copyWith(useAmoled: value);
|
state = state.copyWith(useAmoled: value);
|
||||||
await _saveToStorage();
|
await _saveToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to convert string to ThemeMode
|
|
||||||
ThemeMode _themeModeFromString(String? value) {
|
ThemeMode _themeModeFromString(String? value) {
|
||||||
if (value == null) return ThemeMode.system;
|
if (value == null) return ThemeMode.system;
|
||||||
return ThemeMode.values.firstWhere(
|
return ThemeMode.values.firstWhere(
|
||||||
@@ -91,3 +81,4 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ class PaletteService {
|
|||||||
static final PaletteService instance = PaletteService._();
|
static final PaletteService instance = PaletteService._();
|
||||||
PaletteService._();
|
PaletteService._();
|
||||||
|
|
||||||
/// Cache for already computed colors
|
|
||||||
final Map<String, Color> _colorCache = {};
|
final Map<String, Color> _colorCache = {};
|
||||||
|
|
||||||
/// Extract dominant color from a network image URL
|
/// Extract dominant color from a network image URL
|
||||||
@@ -47,7 +46,6 @@ class PaletteService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract dominant color from a local file path
|
|
||||||
Future<Color?> extractDominantColorFromFile(String? filePath) async {
|
Future<Color?> extractDominantColorFromFile(String? filePath) async {
|
||||||
if (filePath == null || filePath.isEmpty) return null;
|
if (filePath == null || filePath.isEmpty) return null;
|
||||||
|
|
||||||
@@ -81,12 +79,10 @@ class PaletteService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the color cache
|
|
||||||
void clearCache() {
|
void clearCache() {
|
||||||
_colorCache.clear();
|
_colorCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get cached color without computing
|
|
||||||
Color? getCached(String? imageUrl) {
|
Color? getCached(String? imageUrl) {
|
||||||
if (imageUrl == null) return null;
|
if (imageUrl == null) return null;
|
||||||
return _colorCache[imageUrl];
|
return _colorCache[imageUrl];
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ class SettingsGroup extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single settings item that can be used inside SettingsGroup
|
|
||||||
class SettingsItem extends StatelessWidget {
|
class SettingsItem extends StatelessWidget {
|
||||||
|
|
||||||
final IconData? icon;
|
final IconData? icon;
|
||||||
final String title;
|
final String title;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
@@ -125,8 +125,8 @@ class SettingsItem extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A switch settings item for SettingsGroup
|
|
||||||
class SettingsSwitchItem extends StatelessWidget {
|
class SettingsSwitchItem extends StatelessWidget {
|
||||||
|
|
||||||
final IconData? icon;
|
final IconData? icon;
|
||||||
final String title;
|
final String title;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
@@ -213,8 +213,8 @@ class SettingsSwitchItem extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Section header for settings groups
|
|
||||||
class SettingsSectionHeader extends StatelessWidget {
|
class SettingsSectionHeader extends StatelessWidget {
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
|
|
||||||
const SettingsSectionHeader({super.key, required this.title});
|
const SettingsSectionHeader({super.key, required this.title});
|
||||||
|
|||||||
Reference in New Issue
Block a user