refactor: clean up redundant comments and code

This commit is contained in:
zarzet
2026-02-04 10:04:44 +07:00
parent 2dc4cef583
commit 24897e25e2
16 changed files with 43 additions and 232 deletions
+29 -93
View File
@@ -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)
}
+2 -7
View File
@@ -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 {
+5 -15
View File
@@ -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
}
+2 -35
View File
@@ -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))
}
+2 -5
View File
@@ -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
View File
@@ -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);
-1
View File
@@ -292,7 +292,6 @@ void setUseAllFilesAccess(bool enabled) {
_saveSettings();
}
// Local Library Settings
void setLocalLibraryEnabled(bool enabled) {
state = state.copyWith(localLibraryEnabled: enabled);
_saveSettings();
-1
View File
@@ -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))));
-20
View File
@@ -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(
-2
View File
@@ -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(
-1
View File
@@ -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))));
+1 -3
View File
@@ -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(
-1
View File
@@ -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;
+2 -24
View File
@@ -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();