mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-05 20:27:55 +02:00
refactor: clean up redundant comments and code
This commit is contained in:
@@ -54,17 +54,14 @@ func ReadID3Tags(filePath string) (*AudioMetadata, error) {
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
|
||||
// Try ID3v2 first (at beginning of file)
|
||||
id3v2, err := readID3v2(file)
|
||||
if err == nil && id3v2 != nil {
|
||||
metadata = id3v2
|
||||
}
|
||||
|
||||
// If ID3v2 failed or is incomplete, try ID3v1 (at end of file)
|
||||
if metadata.Title == "" || metadata.Artist == "" {
|
||||
id3v1, err := readID3v1(file)
|
||||
if err == nil && id3v1 != nil {
|
||||
// Fill in missing fields
|
||||
if metadata.Title == "" {
|
||||
metadata.Title = id3v1.Title
|
||||
}
|
||||
@@ -94,35 +91,28 @@ func ReadID3Tags(filePath string) (*AudioMetadata, error) {
|
||||
func readID3v2(file *os.File) (*AudioMetadata, error) {
|
||||
file.Seek(0, io.SeekStart)
|
||||
|
||||
// Read ID3v2 header (10 bytes)
|
||||
header := make([]byte, 10)
|
||||
if _, err := io.ReadFull(file, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check for "ID3" identifier
|
||||
if string(header[0:3]) != "ID3" {
|
||||
return nil, fmt.Errorf("no ID3v2 header")
|
||||
}
|
||||
|
||||
// Get version
|
||||
majorVersion := header[3]
|
||||
// minorVersion := header[4]
|
||||
flags := header[5]
|
||||
unsync := (flags & 0x80) != 0
|
||||
extendedHeader := (flags & 0x40) != 0
|
||||
footerPresent := (flags & 0x10) != 0
|
||||
|
||||
// Get tag size (syncsafe integer)
|
||||
size := int(header[6])<<21 | int(header[7])<<14 | int(header[8])<<7 | int(header[9])
|
||||
|
||||
// Read all tag data
|
||||
tagData := make([]byte, size)
|
||||
if _, err := io.ReadFull(file, tagData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Remove footer if present (10 bytes, starts with "3DI")
|
||||
if footerPresent && len(tagData) >= 10 {
|
||||
footerStart := len(tagData) - 10
|
||||
if footerStart >= 0 && string(tagData[footerStart:footerStart+3]) == "3DI" {
|
||||
@@ -130,7 +120,6 @@ func readID3v2(file *os.File) (*AudioMetadata, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Skip extended header if present
|
||||
if extendedHeader {
|
||||
if skip := extendedHeaderSize(tagData, majorVersion); skip > 0 && skip < len(tagData) {
|
||||
tagData = tagData[skip:]
|
||||
@@ -139,11 +128,9 @@ func readID3v2(file *os.File) (*AudioMetadata, error) {
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
|
||||
// Parse frames based on version
|
||||
if majorVersion == 2 {
|
||||
parseID3v22Frames(tagData, metadata, unsync)
|
||||
} else {
|
||||
// ID3v2.3 and ID3v2.4
|
||||
parseID3v23Frames(tagData, metadata, majorVersion, unsync)
|
||||
}
|
||||
|
||||
@@ -156,7 +143,7 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
||||
for pos+6 < len(data) {
|
||||
frameID := string(data[pos : pos+3])
|
||||
if frameID[0] == 0 {
|
||||
break // Padding
|
||||
break
|
||||
}
|
||||
|
||||
frameSize := int(data[pos+3])<<16 | int(data[pos+4])<<8 | int(data[pos+5])
|
||||
@@ -171,21 +158,21 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
||||
value := firstTextValue(extractTextFrame(frameData))
|
||||
|
||||
switch frameID {
|
||||
case "TT2": // Title
|
||||
case "TT2":
|
||||
metadata.Title = value
|
||||
case "TP1": // Artist
|
||||
case "TP1":
|
||||
metadata.Artist = value
|
||||
case "TP2": // Album Artist
|
||||
case "TP2":
|
||||
metadata.AlbumArtist = value
|
||||
case "TAL": // Album
|
||||
case "TAL":
|
||||
metadata.Album = value
|
||||
case "TYE": // Year
|
||||
case "TYE":
|
||||
metadata.Year = value
|
||||
case "TCO": // Genre
|
||||
case "TCO":
|
||||
metadata.Genre = cleanGenre(value)
|
||||
case "TRK": // Track
|
||||
case "TRK":
|
||||
metadata.TrackNumber = parseTrackNumber(value)
|
||||
case "TPA": // Disc
|
||||
case "TPA":
|
||||
metadata.DiscNumber = parseTrackNumber(value)
|
||||
}
|
||||
|
||||
@@ -199,7 +186,7 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
for pos+10 < len(data) {
|
||||
frameID := string(data[pos : pos+4])
|
||||
if frameID[0] == 0 {
|
||||
break // Padding
|
||||
break
|
||||
}
|
||||
|
||||
var frameSize int
|
||||
@@ -238,7 +225,7 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
pos += 10 + frameSize
|
||||
continue
|
||||
}
|
||||
frameData = frameData[1:] // skip group ID
|
||||
frameData = frameData[1:]
|
||||
}
|
||||
if tagUnsync {
|
||||
frameData = removeUnsync(frameData)
|
||||
@@ -246,18 +233,18 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
} else if version == 4 {
|
||||
// ID3v2.4 format flags: grouping, compression, encryption, unsync, data length indicator
|
||||
const (
|
||||
id3v24FlagGrouping = 0x40
|
||||
id3v24FlagCompression = 0x08
|
||||
id3v24FlagEncryption = 0x04
|
||||
id3v24FlagUnsync = 0x02
|
||||
id3v24FlagDataLen = 0x01
|
||||
id3v24FlagGrouping = 0x40
|
||||
id3v24FlagCompression = 0x08
|
||||
id3v24FlagEncryption = 0x04
|
||||
id3v24FlagUnsync = 0x02
|
||||
id3v24FlagDataLen = 0x01
|
||||
)
|
||||
if formatFlags&id3v24FlagGrouping != 0 {
|
||||
if len(frameData) < 1 {
|
||||
pos += 10 + frameSize
|
||||
continue
|
||||
}
|
||||
frameData = frameData[1:] // skip group ID
|
||||
frameData = frameData[1:]
|
||||
}
|
||||
if formatFlags&id3v24FlagDataLen != 0 {
|
||||
if len(frameData) < 4 {
|
||||
@@ -278,26 +265,26 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
value := firstTextValue(extractTextFrame(frameData))
|
||||
|
||||
switch frameID {
|
||||
case "TIT2": // Title
|
||||
case "TIT2":
|
||||
metadata.Title = value
|
||||
case "TPE1": // Artist
|
||||
case "TPE1":
|
||||
metadata.Artist = value
|
||||
case "TPE2": // Album Artist
|
||||
case "TPE2":
|
||||
metadata.AlbumArtist = value
|
||||
case "TALB": // Album
|
||||
case "TALB":
|
||||
metadata.Album = value
|
||||
case "TYER", "TDRC": // Year
|
||||
case "TYER", "TDRC":
|
||||
metadata.Year = value
|
||||
if len(value) >= 4 {
|
||||
metadata.Date = value
|
||||
}
|
||||
case "TCON": // Genre
|
||||
case "TCON":
|
||||
metadata.Genre = cleanGenre(value)
|
||||
case "TRCK": // Track
|
||||
case "TRCK":
|
||||
metadata.TrackNumber = parseTrackNumber(value)
|
||||
case "TPOS": // Disc
|
||||
case "TPOS":
|
||||
metadata.DiscNumber = parseTrackNumber(value)
|
||||
case "TSRC": // ISRC
|
||||
case "TSRC":
|
||||
metadata.ISRC = value
|
||||
}
|
||||
|
||||
@@ -307,7 +294,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) {
|
||||
// Seek to last 128 bytes
|
||||
if _, err := file.Seek(-128, io.SeekEnd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -317,7 +303,6 @@ func readID3v1(file *os.File) (*AudioMetadata, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check for "TAG" identifier
|
||||
if string(tag[0:3]) != "TAG" {
|
||||
return nil, fmt.Errorf("no ID3v1 tag")
|
||||
}
|
||||
@@ -334,7 +319,6 @@ func readID3v1(file *os.File) (*AudioMetadata, error) {
|
||||
metadata.TrackNumber = int(tag[126])
|
||||
}
|
||||
|
||||
// Genre index
|
||||
genreIndex := int(tag[127])
|
||||
if genreIndex < len(id3v1Genres) {
|
||||
metadata.Genre = id3v1Genres[genreIndex]
|
||||
@@ -372,7 +356,6 @@ func decodeUTF16(data []byte) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check BOM
|
||||
var littleEndian bool
|
||||
if data[0] == 0xFF && data[1] == 0xFE {
|
||||
littleEndian = true
|
||||
@@ -424,7 +407,6 @@ func cleanGenre(genre string) string {
|
||||
if end > 0 {
|
||||
numStr := genre[1:end]
|
||||
if num, err := strconv.Atoi(numStr); err == nil && num < len(id3v1Genres) {
|
||||
// If there's text after the number, use it
|
||||
if end+1 < len(genre) {
|
||||
return genre[end+1:]
|
||||
}
|
||||
@@ -513,14 +495,12 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
|
||||
quality := &MP3Quality{}
|
||||
|
||||
// Get file size for duration estimation
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileSize := stat.Size()
|
||||
|
||||
// Skip ID3v2 header if present
|
||||
header := make([]byte, 10)
|
||||
if _, err := io.ReadFull(file, header); err != nil {
|
||||
return nil, err
|
||||
@@ -532,25 +512,20 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
audioStart = 10 + tagSize
|
||||
}
|
||||
|
||||
// Seek to audio start
|
||||
file.Seek(audioStart, io.SeekStart)
|
||||
|
||||
// Find first valid MP3 frame
|
||||
frameHeader := make([]byte, 4)
|
||||
for i := 0; i < 10000; i++ { // Search first 10KB
|
||||
if _, err := io.ReadFull(file, frameHeader); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Check for sync word (11 set bits)
|
||||
if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 {
|
||||
// Parse frame header
|
||||
version := (frameHeader[1] >> 3) & 0x03
|
||||
layer := (frameHeader[1] >> 1) & 0x03
|
||||
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
|
||||
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
|
||||
|
||||
// Get sample rate
|
||||
sampleRates := [][]int{
|
||||
{11025, 12000, 8000}, // MPEG 2.5
|
||||
{0, 0, 0}, // Reserved
|
||||
@@ -574,7 +549,7 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
|
||||
// Estimate duration from file size and bitrate
|
||||
if quality.Bitrate > 0 {
|
||||
audioSize := fileSize - audioStart - 128 // Subtract ID3v1 tag
|
||||
audioSize := fileSize - audioStart - 128
|
||||
if audioSize > 0 {
|
||||
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
|
||||
}
|
||||
@@ -583,7 +558,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
break
|
||||
}
|
||||
|
||||
// Seek back 3 bytes to continue search
|
||||
file.Seek(-3, io.SeekCurrent)
|
||||
}
|
||||
|
||||
@@ -648,13 +622,11 @@ type oggPage struct {
|
||||
|
||||
// readOggPageWithHeader reads a single Ogg page including header info
|
||||
func readOggPageWithHeader(file *os.File) (*oggPage, error) {
|
||||
// Read page header
|
||||
header := make([]byte, 27)
|
||||
if _, err := io.ReadFull(file, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check capture pattern "OggS"
|
||||
if string(header[0:4]) != "OggS" {
|
||||
return nil, fmt.Errorf("not an Ogg page")
|
||||
}
|
||||
@@ -662,19 +634,16 @@ func readOggPageWithHeader(file *os.File) (*oggPage, error) {
|
||||
headerType := header[5]
|
||||
numSegments := int(header[26])
|
||||
|
||||
// Read segment table
|
||||
segmentTable := make([]byte, numSegments)
|
||||
if _, err := io.ReadFull(file, segmentTable); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate total page size
|
||||
var pageSize int
|
||||
for _, seg := range segmentTable {
|
||||
pageSize += int(seg)
|
||||
}
|
||||
|
||||
// Read page data
|
||||
pageData := make([]byte, pageSize)
|
||||
if _, err := io.ReadFull(file, pageData); err != nil {
|
||||
return nil, err
|
||||
@@ -734,7 +703,6 @@ func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error
|
||||
}
|
||||
|
||||
if len(cur)+segLen > maxPacketSize {
|
||||
// Skip this oversized packet
|
||||
cur = nil
|
||||
skipPacket = true
|
||||
offset += segLen
|
||||
@@ -796,7 +764,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip vendor string
|
||||
if vendorLen > uint32(len(data)-4) {
|
||||
return
|
||||
}
|
||||
@@ -805,20 +772,18 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
return
|
||||
}
|
||||
|
||||
// Read comment count
|
||||
var commentCount uint32
|
||||
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Read each comment
|
||||
for i := uint32(0); i < commentCount && i < 100; i++ {
|
||||
var commentLen uint32
|
||||
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if commentLen > 10000 { // Sanity check
|
||||
if commentLen > 10000 {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -827,7 +792,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
break
|
||||
}
|
||||
|
||||
// Parse "KEY=VALUE" format
|
||||
parts := strings.SplitN(string(comment), "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
@@ -880,7 +844,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
|
||||
streamType := detectOggStreamType(packets)
|
||||
if streamType == oggStreamUnknown {
|
||||
// Fallback to file extension
|
||||
if strings.HasSuffix(strings.ToLower(filePath), ".opus") {
|
||||
streamType = oggStreamOpus
|
||||
} else {
|
||||
@@ -910,7 +873,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get file size for duration estimation
|
||||
stat, err := file.Stat()
|
||||
if err == nil {
|
||||
// Very rough duration estimate based on file size
|
||||
@@ -971,7 +933,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read ID3v2 header
|
||||
header := make([]byte, 10)
|
||||
if _, err := io.ReadFull(file, header); err != nil {
|
||||
return nil, "", err
|
||||
@@ -1044,7 +1005,6 @@ func parseAPICFrame(data []byte, version byte) ([]byte, string) {
|
||||
encoding := data[pos]
|
||||
pos++
|
||||
|
||||
// Read MIME type
|
||||
var mimeType string
|
||||
if version == 2 {
|
||||
// ID3v2.2: 3-byte image format (JPG, PNG)
|
||||
@@ -1075,8 +1035,6 @@ func parseAPICFrame(data []byte, version byte) ([]byte, string) {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Skip picture type
|
||||
// pictureType := data[pos]
|
||||
pos++
|
||||
|
||||
// Skip description (null-terminated, may be UTF-16)
|
||||
@@ -1085,7 +1043,7 @@ func parseAPICFrame(data []byte, version byte) ([]byte, string) {
|
||||
for pos < len(data) && data[pos] != 0 {
|
||||
pos++
|
||||
}
|
||||
pos++ // Skip null
|
||||
pos++
|
||||
} else {
|
||||
// UTF-16: look for double null
|
||||
for pos+1 < len(data) {
|
||||
@@ -1101,7 +1059,6 @@ func parseAPICFrame(data []byte, version byte) ([]byte, string) {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Rest is image data
|
||||
return data[pos:], mimeType
|
||||
}
|
||||
|
||||
@@ -1157,7 +1114,6 @@ func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
|
||||
|
||||
reader := bytes.NewReader(data)
|
||||
|
||||
// Skip vendor string
|
||||
var vendorLen uint32
|
||||
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
|
||||
return nil, ""
|
||||
@@ -1167,7 +1123,6 @@ func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
|
||||
}
|
||||
reader.Seek(int64(vendorLen), io.SeekCurrent)
|
||||
|
||||
// Read comment count
|
||||
var commentCount uint32
|
||||
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
|
||||
return nil, ""
|
||||
@@ -1188,7 +1143,6 @@ func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
|
||||
break
|
||||
}
|
||||
|
||||
// Check for METADATA_BLOCK_PICTURE=
|
||||
key := "METADATA_BLOCK_PICTURE="
|
||||
if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key {
|
||||
// Base64-encoded FLAC picture block
|
||||
@@ -1200,7 +1154,6 @@ func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
|
||||
}
|
||||
decoded = decoded[:n]
|
||||
|
||||
// Parse FLAC picture block
|
||||
imageData, mimeType := parseFLACPictureBlock(decoded)
|
||||
if len(imageData) > 0 {
|
||||
return imageData, mimeType
|
||||
@@ -1219,43 +1172,35 @@ func parseFLACPictureBlock(data []byte) ([]byte, string) {
|
||||
|
||||
reader := bytes.NewReader(data)
|
||||
|
||||
// Picture type (4 bytes)
|
||||
var pictureType uint32
|
||||
binary.Read(reader, binary.BigEndian, &pictureType)
|
||||
|
||||
// MIME type length (4 bytes)
|
||||
var mimeLen uint32
|
||||
binary.Read(reader, binary.BigEndian, &mimeLen)
|
||||
if mimeLen > 256 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// MIME type
|
||||
mimeBytes := make([]byte, mimeLen)
|
||||
reader.Read(mimeBytes)
|
||||
mimeType := string(mimeBytes)
|
||||
|
||||
// Description length (4 bytes)
|
||||
var descLen uint32
|
||||
binary.Read(reader, binary.BigEndian, &descLen)
|
||||
if descLen > 10000 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Skip description
|
||||
reader.Seek(int64(descLen), io.SeekCurrent)
|
||||
|
||||
// Skip width, height, color depth, colors used (16 bytes)
|
||||
reader.Seek(16, io.SeekCurrent)
|
||||
|
||||
// Image data length (4 bytes)
|
||||
var dataLen uint32
|
||||
binary.Read(reader, binary.BigEndian, &dataLen)
|
||||
if dataLen > 10000000 { // 10MB
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Image data
|
||||
imageData := make([]byte, dataLen)
|
||||
reader.Read(imageData)
|
||||
|
||||
@@ -1281,7 +1226,6 @@ func base64StdDecode(dst, src []byte) (int, error) {
|
||||
|
||||
si, di := 0, 0
|
||||
for si < len(src) {
|
||||
// Skip whitespace and newlines
|
||||
for si < len(src) && (src[si] == '\n' || src[si] == '\r' || src[si] == ' ' || src[si] == '\t') {
|
||||
si++
|
||||
}
|
||||
@@ -1289,7 +1233,6 @@ func base64StdDecode(dst, src []byte) (int, error) {
|
||||
break
|
||||
}
|
||||
|
||||
// Read 4 characters
|
||||
var vals [4]byte
|
||||
var valCount int
|
||||
for valCount < 4 && si < len(src) {
|
||||
@@ -1310,7 +1253,6 @@ func base64StdDecode(dst, src []byte) (int, error) {
|
||||
break
|
||||
}
|
||||
|
||||
// Decode
|
||||
if di < len(dst) {
|
||||
dst[di] = vals[0]<<2 | vals[1]>>4
|
||||
di++
|
||||
@@ -1334,7 +1276,6 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||
|
||||
switch ext {
|
||||
case ".flac":
|
||||
// Use existing ExtractCoverArt function
|
||||
data, err := ExtractCoverArt(filePath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
@@ -1372,7 +1313,6 @@ func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||
}
|
||||
hash := hashString(cacheKey)
|
||||
|
||||
// Check if cover already cached
|
||||
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
|
||||
pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash))
|
||||
|
||||
@@ -1383,18 +1323,15 @@ func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||
return pngPath, nil
|
||||
}
|
||||
|
||||
// Extract cover art
|
||||
imageData, mimeType, err := extractAnyCoverArt(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Ensure cache directory exists
|
||||
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create cache dir: %w", err)
|
||||
}
|
||||
|
||||
// Determine file extension
|
||||
var cachePath string
|
||||
if strings.Contains(mimeType, "png") {
|
||||
cachePath = pngPath
|
||||
@@ -1402,7 +1339,6 @@ func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||
cachePath = jpgPath
|
||||
}
|
||||
|
||||
// Write to file
|
||||
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write cover: %w", err)
|
||||
}
|
||||
|
||||
@@ -194,7 +194,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
|
||||
// Trim whitespace from string fields to prevent filename/path issues
|
||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||
@@ -1447,8 +1446,8 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
||||
"disc_number": track.DiscNumber,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType, // track, album, or playlist
|
||||
"album_type": track.AlbumType, // album, single, ep, compilation
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1544,7 +1543,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
response["tracks"] = tracks
|
||||
}
|
||||
|
||||
// Add album info if present
|
||||
if result.Album != nil {
|
||||
response["album"] = map[string]interface{}{
|
||||
"id": result.Album.ID,
|
||||
@@ -1662,7 +1660,6 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
if trackCover == "" {
|
||||
trackCover = album.CoverURL
|
||||
}
|
||||
// Use track number from extension, fallback to index+1 if not provided
|
||||
trackNum := track.TrackNumber
|
||||
if trackNum == 0 {
|
||||
trackNum = i + 1
|
||||
@@ -1840,7 +1837,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
"provider_id": artist.ProviderID,
|
||||
}
|
||||
|
||||
// Add header image if present
|
||||
if artist.HeaderImage != "" {
|
||||
response["header_image"] = artist.HeaderImage
|
||||
}
|
||||
@@ -1849,7 +1845,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
response["listeners"] = artist.Listeners
|
||||
}
|
||||
|
||||
// Add top tracks if present
|
||||
if len(artist.TopTracks) > 0 {
|
||||
topTracks := make([]map[string]interface{}, len(artist.TopTracks))
|
||||
for i, track := range artist.TopTracks {
|
||||
|
||||
@@ -31,8 +31,8 @@ type ExtTrackMetadata struct {
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
ItemType string `json:"item_type,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
// Enrichment fields from Odesli/song.link
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
@@ -176,7 +176,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
return nil, fmt.Errorf("searchTracks returned null")
|
||||
}
|
||||
|
||||
// Convert result to Go struct
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
@@ -379,7 +378,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
||||
trackJSON, err := json.Marshal(track)
|
||||
if err != nil {
|
||||
GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err)
|
||||
return track, nil // Return original on error
|
||||
return track, nil
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -399,7 +398,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
||||
} else {
|
||||
GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err)
|
||||
}
|
||||
return track, nil // Return original on error
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// If extension doesn't implement enrichTrack or returns null, return original
|
||||
@@ -843,14 +842,12 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
|
||||
// For tracks from extension search, use the track ID directly (e.g., "youtube:VIDEO_ID")
|
||||
// The extension already knows how to handle this ID
|
||||
trackID := req.SpotifyID // This contains the extension's track ID (e.g., "youtube:xxx")
|
||||
trackID := req.SpotifyID
|
||||
|
||||
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
||||
|
||||
outputPath := buildOutputPath(req)
|
||||
|
||||
// Download directly using the track ID from the extension
|
||||
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, float64(percent), 0, 0)
|
||||
@@ -879,7 +876,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// If extension has skipMetadataEnrichment, copy metadata
|
||||
if ext.Manifest.SkipMetadataEnrichment {
|
||||
resp.SkipMetadataEnrichment = true
|
||||
if result.Title != "" {
|
||||
@@ -946,12 +942,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
|
||||
// Continue with priority list
|
||||
for _, providerID := range priority {
|
||||
// Skip if we already tried this as source
|
||||
if providerID == req.Source {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip built-in providers if skipBuiltIn is set
|
||||
if skipBuiltIn && isBuiltInProvider(providerID) {
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
|
||||
continue
|
||||
@@ -1065,7 +1059,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// If extension has skipMetadataEnrichment and returned metadata, use it
|
||||
if ext.Manifest.SkipMetadataEnrichment {
|
||||
resp.SkipMetadataEnrichment = true
|
||||
// Copy metadata from extension result if provided
|
||||
@@ -1276,7 +1269,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
// Return empty array instead of error for no results
|
||||
return []ExtTrackMetadata{}, nil
|
||||
}
|
||||
|
||||
@@ -1291,7 +1283,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
return nil, fmt.Errorf("failed to parse search result: %w", err)
|
||||
}
|
||||
|
||||
// Return empty array if no tracks found
|
||||
if tracks == nil {
|
||||
tracks = []ExtTrackMetadata{}
|
||||
}
|
||||
@@ -1362,7 +1353,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
return nil, fmt.Errorf("failed to parse URL handle result: %w", err)
|
||||
}
|
||||
|
||||
// Set provider ID on tracks
|
||||
if handleResult.Track != nil {
|
||||
handleResult.Track.ProviderID = p.extension.ID
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ var (
|
||||
libraryScanProgressMu sync.RWMutex
|
||||
libraryScanCancel chan struct{}
|
||||
libraryScanCancelMu sync.Mutex
|
||||
libraryCoverCacheDir string // Directory to cache extracted cover art
|
||||
libraryCoverCacheDir string
|
||||
libraryCoverCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
@@ -73,7 +73,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
return "[]", fmt.Errorf("folder path is empty")
|
||||
}
|
||||
|
||||
// Check if folder exists
|
||||
info, err := os.Stat(folderPath)
|
||||
if err != nil {
|
||||
return "[]", fmt.Errorf("folder not found: %w", err)
|
||||
@@ -82,12 +81,10 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
return "[]", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||
}
|
||||
|
||||
// Reset progress
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress = LibraryScanProgress{}
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Create cancel channel
|
||||
libraryScanCancelMu.Lock()
|
||||
if libraryScanCancel != nil {
|
||||
close(libraryScanCancel)
|
||||
@@ -96,11 +93,10 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
cancelCh := libraryScanCancel
|
||||
libraryScanCancelMu.Unlock()
|
||||
|
||||
// First pass: count audio files
|
||||
var audioFiles []string
|
||||
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Skip errors, continue walking
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
@@ -136,7 +132,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
|
||||
GoLog("[LibraryScan] Found %d audio files to scan\n", totalFiles)
|
||||
|
||||
// Second pass: read metadata from each file
|
||||
results := make([]LibraryScanResult, 0, totalFiles)
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
errorCount := 0
|
||||
@@ -148,14 +143,12 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
default:
|
||||
}
|
||||
|
||||
// Update progress
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = i + 1
|
||||
libraryScanProgress.CurrentFile = filepath.Base(filePath)
|
||||
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Read metadata
|
||||
result, err := scanAudioFile(filePath, scanTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
@@ -166,7 +159,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
results = append(results, *result)
|
||||
}
|
||||
|
||||
// Mark complete
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ErrorCount = errorCount
|
||||
libraryScanProgress.IsComplete = true
|
||||
@@ -193,7 +185,6 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
Format: strings.TrimPrefix(ext, "."),
|
||||
}
|
||||
|
||||
// Try to extract and cache cover art
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
@@ -204,7 +195,6 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Try to read metadata based on format
|
||||
switch ext {
|
||||
case ".flac":
|
||||
return scanFLACFile(filePath, result)
|
||||
@@ -213,10 +203,8 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
case ".mp3":
|
||||
return scanMP3File(filePath, result)
|
||||
case ".opus", ".ogg":
|
||||
// Opus files often use same container as Ogg Vorbis
|
||||
return scanOggFile(filePath, result)
|
||||
default:
|
||||
// Fallback: use filename as title
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
}
|
||||
@@ -225,7 +213,6 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
// Fallback to filename
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
@@ -239,7 +226,6 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
||||
result.ReleaseDate = metadata.Date
|
||||
result.Genre = metadata.Genre
|
||||
|
||||
// Read audio quality
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
@@ -249,7 +235,6 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have at least a title
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
@@ -265,14 +250,12 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
||||
|
||||
// scanM4AFile reads metadata from M4A/AAC file
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
// M4A metadata reading is limited, try audio quality at least
|
||||
quality, err := GetM4AQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
// Fallback to filename parsing
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
@@ -298,7 +281,6 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
}
|
||||
result.ISRC = metadata.ISRC
|
||||
|
||||
// Get audio quality info
|
||||
quality, err := GetMP3Quality(filePath)
|
||||
if err == nil {
|
||||
result.SampleRate = quality.SampleRate
|
||||
@@ -306,7 +288,6 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
result.Duration = quality.Duration
|
||||
}
|
||||
|
||||
// Ensure we have at least a title
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
@@ -338,7 +319,6 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
result.Genre = metadata.Genre
|
||||
result.ReleaseDate = metadata.Date
|
||||
|
||||
// Get audio quality info
|
||||
quality, err := GetOggQuality(filePath)
|
||||
if err == nil {
|
||||
result.SampleRate = quality.SampleRate
|
||||
@@ -346,7 +326,6 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
result.Duration = quality.Duration
|
||||
}
|
||||
|
||||
// Ensure we have at least a title
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
@@ -364,16 +343,8 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
|
||||
// Common patterns:
|
||||
// "Artist - Title"
|
||||
// "01 - Title"
|
||||
// "01. Title"
|
||||
// "Title"
|
||||
|
||||
// Try "Artist - Title" pattern
|
||||
parts := strings.SplitN(filename, " - ", 2)
|
||||
if len(parts) == 2 {
|
||||
// Check if first part looks like a track number
|
||||
if len(parts[0]) <= 3 && isNumeric(parts[0]) {
|
||||
result.TrackName = parts[1]
|
||||
result.ArtistName = "Unknown Artist"
|
||||
@@ -382,9 +353,7 @@ func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanR
|
||||
result.TrackName = parts[1]
|
||||
}
|
||||
} else {
|
||||
// Try "01. Title" or "01 Title" pattern
|
||||
if len(filename) > 3 && isNumeric(filename[:2]) {
|
||||
// Skip track number
|
||||
title := strings.TrimLeft(filename[2:], " .-")
|
||||
result.TrackName = title
|
||||
} else {
|
||||
@@ -393,7 +362,6 @@ func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanR
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
|
||||
// Use parent folder as album name
|
||||
dir := filepath.Dir(filePath)
|
||||
result.AlbumName = filepath.Base(dir)
|
||||
if result.AlbumName == "." || result.AlbumName == "" {
|
||||
@@ -415,7 +383,6 @@ func isNumeric(s string) bool {
|
||||
|
||||
// generateLibraryID creates a unique ID for a library item
|
||||
func generateLibraryID(filePath string) string {
|
||||
// Use file path hash as ID
|
||||
return fmt.Sprintf("lib_%x", hashString(filePath))
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ var (
|
||||
credentialsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
|
||||
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
||||
|
||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
@@ -141,7 +140,7 @@ type TrackMetadata struct {
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
}
|
||||
|
||||
type AlbumTrackMetadata struct {
|
||||
@@ -210,7 +209,7 @@ type ArtistAlbumMetadata struct {
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
Images string `json:"images"`
|
||||
AlbumType string `json:"album_type"` // album, single, compilation
|
||||
AlbumType string `json:"album_type"`
|
||||
Artists string `json:"artists"`
|
||||
}
|
||||
|
||||
@@ -532,7 +531,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
|
||||
albumImage := firstImageURL(data.Images)
|
||||
|
||||
// Get first artist ID
|
||||
var firstArtistId string
|
||||
if len(data.Artists) > 0 {
|
||||
firstArtistId = data.Artists[0].ID
|
||||
@@ -565,7 +563,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
|
||||
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
||||
|
||||
// Collect track IDs for parallel ISRC fetching
|
||||
trackIDs := make([]string, len(allTrackItems))
|
||||
for i, item := range allTrackItems {
|
||||
trackIDs[i] = item.ID
|
||||
|
||||
@@ -1034,14 +1034,12 @@ void removeItem(String id) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get base download directory
|
||||
String baseDir = state.outputDir;
|
||||
if (baseDir.isEmpty) {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
baseDir = dir.path;
|
||||
}
|
||||
|
||||
// Create failed_downloads subfolder
|
||||
final failedDownloadsDir = '$baseDir/failed_downloads';
|
||||
final failedDir = Directory(failedDownloadsDir);
|
||||
if (!await failedDir.exists()) {
|
||||
@@ -1057,11 +1055,9 @@ void removeItem(String id) {
|
||||
final file = File(filePath);
|
||||
final bool fileExists = await file.exists();
|
||||
|
||||
// Build content for new entries
|
||||
final buffer = StringBuffer();
|
||||
|
||||
if (!fileExists) {
|
||||
// New file - add header
|
||||
buffer.writeln('# SpotiFLAC Failed Downloads');
|
||||
buffer.writeln('# Date: $dateStr');
|
||||
buffer.writeln('#');
|
||||
@@ -1069,7 +1065,6 @@ void removeItem(String id) {
|
||||
buffer.writeln('');
|
||||
}
|
||||
|
||||
// Add timestamp for this batch
|
||||
final timeStr = '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
|
||||
|
||||
for (final item in failedItems) {
|
||||
@@ -1081,7 +1076,6 @@ void removeItem(String id) {
|
||||
buffer.writeln('[$timeStr] ${track.name} - ${track.artistName} | $spotifyUrl | $error');
|
||||
}
|
||||
|
||||
// Append or create file
|
||||
if (fileExists) {
|
||||
await file.writeAsString(buffer.toString(), mode: FileMode.append);
|
||||
_log.i('Appended ${failedItems.length} failed downloads to: $filePath');
|
||||
@@ -1553,7 +1547,6 @@ void removeItem(String id) {
|
||||
|
||||
_log.d('Opus Metadata map content: $metadata');
|
||||
|
||||
// Handle lyrics based on lyricsMode setting
|
||||
final lyricsMode = settings.lyricsMode;
|
||||
final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both';
|
||||
final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both';
|
||||
@@ -1571,13 +1564,11 @@ void removeItem(String id) {
|
||||
);
|
||||
|
||||
if (lrcContent.isNotEmpty) {
|
||||
// Embed lyrics in file metadata if mode is 'embed' or 'both'
|
||||
if (shouldEmbed) {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
_log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)');
|
||||
}
|
||||
|
||||
// Save external LRC file if mode is 'external' or 'both'
|
||||
if (shouldSaveExternal) {
|
||||
try {
|
||||
final lrcPath = opusPath.replaceAll(RegExp(r'\.opus$', caseSensitive: false), '.lrc');
|
||||
@@ -2137,7 +2128,6 @@ result = await PlatformBridge.downloadWithExtensions(
|
||||
progress: 0.95,
|
||||
);
|
||||
|
||||
// Convert M4A to the selected format
|
||||
final format = tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3';
|
||||
final convertedPath = await FFmpegService.convertM4aToLossy(
|
||||
filePath,
|
||||
@@ -2154,7 +2144,6 @@ result = await PlatformBridge.downloadWithExtensions(
|
||||
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
|
||||
_log.i('Successfully converted M4A to $format: $convertedPath');
|
||||
|
||||
// Embed metadata
|
||||
_log.i('Embedding metadata to $format...');
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
@@ -2333,13 +2322,11 @@ result = await PlatformBridge.downloadWithExtensions(
|
||||
|
||||
_completedInSession++;
|
||||
|
||||
// Check if this track is already in download history
|
||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||
final existingInHistory = historyNotifier.getBySpotifyId(trackToDownload.id) ??
|
||||
(trackToDownload.isrc != null ? historyNotifier.getByIsrc(trackToDownload.isrc!) : null);
|
||||
|
||||
if (wasExisting && existingInHistory != null) {
|
||||
// File exists and is already in download history - skip adding
|
||||
_log.i('Track already in library, skipping history update');
|
||||
await _notificationService.showDownloadComplete(
|
||||
trackName: item.track.name,
|
||||
|
||||
@@ -10,7 +10,6 @@ final _log = AppLogger('LocalLibrary');
|
||||
|
||||
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
||||
|
||||
/// State for local library
|
||||
class LocalLibraryState {
|
||||
final List<LocalLibraryItem> items;
|
||||
final bool isScanning;
|
||||
@@ -92,7 +91,6 @@ class LocalLibraryState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for local library state management
|
||||
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
final LibraryDatabase _db = LibraryDatabase.instance;
|
||||
Timer? _progressTimer;
|
||||
@@ -120,7 +118,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
.map((e) => LocalLibraryItem.fromJson(e))
|
||||
.toList();
|
||||
|
||||
// Load lastScannedAt from SharedPreferences
|
||||
DateTime? lastScannedAt;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -161,7 +158,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
scanErrorCount: 0,
|
||||
);
|
||||
|
||||
// Set cover cache directory before scanning
|
||||
try {
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
final coverCacheDir = '${cacheDir.path}/library_covers';
|
||||
@@ -171,23 +167,19 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.w('Failed to set cover cache directory: $e');
|
||||
}
|
||||
|
||||
// Start progress polling
|
||||
_startProgressPolling();
|
||||
|
||||
try {
|
||||
final results = await PlatformBridge.scanLibraryFolder(folderPath);
|
||||
|
||||
// Convert results to LocalLibraryItem and save to database
|
||||
final items = <LocalLibraryItem>[];
|
||||
for (final json in results) {
|
||||
final item = LocalLibraryItem.fromJson(json);
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
// Batch insert into database
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
|
||||
// Save lastScannedAt to SharedPreferences
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -197,7 +189,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.w('Failed to save lastScannedAt: $e');
|
||||
}
|
||||
|
||||
// Update state
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
isScanning: false,
|
||||
@@ -262,7 +253,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
Future<void> clearLibrary() async {
|
||||
await _db.clearAll();
|
||||
|
||||
// Clear lastScannedAt from SharedPreferences
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_lastScannedAtKey);
|
||||
|
||||
@@ -292,7 +292,6 @@ void setUseAllFilesAccess(bool enabled) {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
// Local Library Settings
|
||||
void setLocalLibraryEnabled(bool enabled) {
|
||||
state = state.copyWith(localLibraryEnabled: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -695,7 +695,6 @@ child: ListTile(
|
||||
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
// Check if track already exists in local library
|
||||
if (isInLocalLibrary) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))));
|
||||
|
||||
@@ -103,7 +103,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
// Selection mode state
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedAlbumIds = {};
|
||||
bool _isFetchingDiscography = false;
|
||||
@@ -112,7 +111,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Setup scroll listener for sticky title
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -322,11 +320,9 @@ return PopScope(
|
||||
if (compilations.isNotEmpty)
|
||||
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)),
|
||||
],
|
||||
// Add padding at bottom for selection bar
|
||||
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
|
||||
],
|
||||
),
|
||||
// Selection action bar
|
||||
if (_isSelectionMode)
|
||||
_buildSelectionBar(context, colorScheme, albums),
|
||||
],
|
||||
@@ -404,14 +400,12 @@ return PopScope(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Close button
|
||||
IconButton(
|
||||
onPressed: _exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: context.l10n.dialogCancel,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Selection info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -433,13 +427,11 @@ return PopScope(
|
||||
],
|
||||
),
|
||||
),
|
||||
// Select all / Deselect button
|
||||
TextButton(
|
||||
onPressed: allSelected ? _deselectAll : () => _selectAll(allAlbums),
|
||||
child: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Download button
|
||||
FilledButton.icon(
|
||||
onPressed: selectedCount > 0 ? () => _downloadSelectedAlbums(context, selectedAlbums) : null,
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
@@ -473,7 +465,6 @@ return PopScope(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Handle bar
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
@@ -483,7 +474,6 @@ return PopScope(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
// Title
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Row(
|
||||
@@ -577,7 +567,6 @@ return PopScope(
|
||||
|
||||
setState(() => _isFetchingDiscography = true);
|
||||
|
||||
// Show progress dialog
|
||||
if (!mounted) {
|
||||
setState(() => _isFetchingDiscography = false);
|
||||
return;
|
||||
@@ -599,7 +588,6 @@ return PopScope(
|
||||
int fetchedCount = 0;
|
||||
int failedCount = 0;
|
||||
|
||||
// Fetch tracks from each album
|
||||
for (final album in albums) {
|
||||
if (!_isFetchingDiscography) break; // Cancelled
|
||||
|
||||
@@ -620,12 +608,10 @@ return PopScope(
|
||||
|
||||
setState(() => _isFetchingDiscography = false);
|
||||
|
||||
// Close progress dialog
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
|
||||
// Show warning if some albums failed
|
||||
if (failedCount > 0 && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.discographyFailedToFetch)),
|
||||
@@ -668,14 +654,12 @@ return PopScope(
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to queue
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
service,
|
||||
qualityOverride: qualityOverride,
|
||||
);
|
||||
|
||||
// Show success message
|
||||
if (mounted) {
|
||||
final message = skippedCount > 0
|
||||
? context.l10n.discographySkippedDownloaded(tracksToQueue.length, skippedCount)
|
||||
@@ -698,14 +682,12 @@ return PopScope(
|
||||
|
||||
Future<List<Track>> _fetchAlbumTracks(ArtistAlbum album) async {
|
||||
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
||||
// Extension album
|
||||
final result = await PlatformBridge.getAlbumWithExtension(album.providerId!, album.id);
|
||||
if (result != null && result['tracks'] != null) {
|
||||
final tracksList = result['tracks'] as List<dynamic>;
|
||||
return tracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
}
|
||||
} else if (album.id.startsWith('deezer:')) {
|
||||
// Deezer album
|
||||
final deezerId = album.id.replaceFirst('deezer:', '');
|
||||
final metadata = await PlatformBridge.getDeezerMetadata('album', deezerId);
|
||||
if (metadata['tracks'] != null) {
|
||||
@@ -713,7 +695,6 @@ return PopScope(
|
||||
return tracksList.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album)).toList();
|
||||
}
|
||||
} else {
|
||||
// Spotify album
|
||||
final url = 'https://open.spotify.com/album/${album.id}';
|
||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (result != null && result['tracks'] != null) {
|
||||
@@ -1068,7 +1049,6 @@ if (hasValidImage)
|
||||
void _handlePopularTrackTap(Track track, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
// Check if track already exists in local library
|
||||
if (isInLocalLibrary) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -513,7 +513,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
Extension? currentSearchExtension;
|
||||
List<SearchFilter> searchFilters = [];
|
||||
|
||||
// Check if using extension search provider
|
||||
final isUsingExtensionSearch = currentSearchProvider != null &&
|
||||
currentSearchProvider.isNotEmpty &&
|
||||
extState.extensions.any((e) => e.id == currentSearchProvider && e.enabled);
|
||||
@@ -2567,7 +2566,6 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
// Check if track already exists in local library
|
||||
if (isInLocalLibrary) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -502,7 +502,6 @@ leading: track.coverUrl != null
|
||||
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
// Check if track already exists in local library
|
||||
if (isInLocalLibrary) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))));
|
||||
|
||||
@@ -28,13 +28,12 @@ class UnifiedLibraryItem {
|
||||
final String artistName;
|
||||
final String albumName;
|
||||
final String? coverUrl;
|
||||
final String? localCoverPath; // For local library items with extracted cover
|
||||
final String? localCoverPath;
|
||||
final String filePath;
|
||||
final String? quality;
|
||||
final DateTime addedAt;
|
||||
final LibraryItemSource source;
|
||||
|
||||
// Original items for navigation
|
||||
final DownloadHistoryItem? historyItem;
|
||||
final LocalLibraryItem? localItem;
|
||||
|
||||
@@ -258,7 +257,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
'Dec',
|
||||
];
|
||||
|
||||
// Search functionality
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
String _searchQuery = '';
|
||||
|
||||
@@ -117,7 +117,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if folder exists
|
||||
if (!await Directory(libraryPath).exists()) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -139,7 +139,6 @@ class HistoryDatabase {
|
||||
final prefs = await _prefs;
|
||||
final lastContainer = prefs.getString('ios_last_container_path');
|
||||
|
||||
// Skip if container hasn't changed
|
||||
if (lastContainer == _currentContainerPath) {
|
||||
_log.d('iOS container path unchanged, skipping migration');
|
||||
return false;
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('LibraryDatabase');
|
||||
|
||||
/// Represents a track in the user's local music library
|
||||
class LocalLibraryItem {
|
||||
final String id;
|
||||
final String trackName;
|
||||
@@ -14,7 +13,7 @@ class LocalLibraryItem {
|
||||
final String albumName;
|
||||
final String? albumArtist;
|
||||
final String filePath;
|
||||
final String? coverPath; // Path to extracted cover art
|
||||
final String? coverPath;
|
||||
final DateTime scannedAt;
|
||||
final String? isrc;
|
||||
final int? trackNumber;
|
||||
@@ -92,7 +91,6 @@ class LocalLibraryItem {
|
||||
String get albumKey => '${albumName.toLowerCase()}|${(albumArtist ?? artistName).toLowerCase()}';
|
||||
}
|
||||
|
||||
/// SQLite database service for local library
|
||||
class LibraryDatabase {
|
||||
static final LibraryDatabase instance = LibraryDatabase._init();
|
||||
static Database? _database;
|
||||
@@ -144,7 +142,6 @@ class LibraryDatabase {
|
||||
)
|
||||
''');
|
||||
|
||||
// Indexes for fast lookups
|
||||
await db.execute('CREATE INDEX idx_library_isrc ON library(isrc)');
|
||||
await db.execute('CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)');
|
||||
await db.execute('CREATE INDEX idx_library_album ON library(album_name, album_artist)');
|
||||
@@ -163,7 +160,6 @@ class LibraryDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert JSON format (camelCase) to DB row (snake_case)
|
||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||
return {
|
||||
'id': json['id'],
|
||||
@@ -186,7 +182,6 @@ class LibraryDatabase {
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert DB row (snake_case) to JSON format (camelCase)
|
||||
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
|
||||
return {
|
||||
'id': row['id'],
|
||||
@@ -209,9 +204,8 @@ class LibraryDatabase {
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== CRUD Operations ====================
|
||||
// CRUD Operations
|
||||
|
||||
/// Insert or update a library item
|
||||
Future<void> upsert(Map<String, dynamic> json) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
@@ -221,7 +215,6 @@ class LibraryDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
/// Batch insert multiple items
|
||||
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
||||
final db = await database;
|
||||
final batch = db.batch();
|
||||
@@ -238,7 +231,6 @@ class LibraryDatabase {
|
||||
_log.i('Batch inserted ${items.length} items');
|
||||
}
|
||||
|
||||
/// Get all library items ordered by album/artist
|
||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
@@ -250,7 +242,6 @@ class LibraryDatabase {
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
/// Get item by ID
|
||||
Future<Map<String, dynamic>?> getById(String id) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
@@ -263,7 +254,6 @@ class LibraryDatabase {
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
/// Get item by ISRC - O(1) with index
|
||||
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
@@ -276,7 +266,6 @@ class LibraryDatabase {
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
/// Check if ISRC exists - O(1) with index
|
||||
Future<bool> existsByIsrc(String isrc) async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery(
|
||||
@@ -286,7 +275,6 @@ class LibraryDatabase {
|
||||
return result.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Find by track name and artist (fuzzy match)
|
||||
Future<List<Map<String, dynamic>>> findByTrackAndArtist(
|
||||
String trackName,
|
||||
String artistName,
|
||||
@@ -300,7 +288,6 @@ class LibraryDatabase {
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
/// Check if track exists by name and artist
|
||||
Future<Map<String, dynamic>?> findExisting({
|
||||
String? isrc,
|
||||
String? trackName,
|
||||
@@ -321,7 +308,6 @@ class LibraryDatabase {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get all ISRCs as Set for fast in-memory lookup
|
||||
Future<Set<String>> getAllIsrcs() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
@@ -330,7 +316,6 @@ class LibraryDatabase {
|
||||
return rows.map((r) => r['isrc'] as String).toSet();
|
||||
}
|
||||
|
||||
/// Get all track keys (name|artist) for matching
|
||||
Future<Set<String>> getAllTrackKeys() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
@@ -339,19 +324,16 @@ class LibraryDatabase {
|
||||
return rows.map((r) => r['match_key'] as String).toSet();
|
||||
}
|
||||
|
||||
/// Delete by file path
|
||||
Future<void> deleteByPath(String filePath) async {
|
||||
final db = await database;
|
||||
await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]);
|
||||
}
|
||||
|
||||
/// Delete by ID
|
||||
Future<void> delete(String id) async {
|
||||
final db = await database;
|
||||
await db.delete('library', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
/// Delete items where file no longer exists
|
||||
Future<int> cleanupMissingFiles() async {
|
||||
final db = await database;
|
||||
final rows = await db.query('library', columns: ['id', 'file_path']);
|
||||
@@ -371,21 +353,18 @@ class LibraryDatabase {
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// Clear all library data
|
||||
Future<void> clearAll() async {
|
||||
final db = await database;
|
||||
await db.delete('library');
|
||||
_log.i('Cleared all library data');
|
||||
}
|
||||
|
||||
/// Get total count
|
||||
Future<int> getCount() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('SELECT COUNT(*) as count FROM library');
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
/// Search library by query
|
||||
Future<List<Map<String, dynamic>>> search(String query, {int limit = 50}) async {
|
||||
final db = await database;
|
||||
final searchQuery = '%${query.toLowerCase()}%';
|
||||
@@ -399,7 +378,6 @@ class LibraryDatabase {
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
/// Close database
|
||||
Future<void> close() async {
|
||||
final db = await database;
|
||||
await db.close();
|
||||
|
||||
Reference in New Issue
Block a user