diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index e9c7844e..81deda9a 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -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) } diff --git a/go_backend/exports.go b/go_backend/exports.go index 2d0ed9c5..0229d2d5 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -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 { diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index ead2d28c..97d0e49c 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -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 } diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index dd133394..20c33f20 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -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)) } diff --git a/go_backend/spotify.go b/go_backend/spotify.go index a81151ef..7bde0f60 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -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 diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 54c20328..0661a626 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -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, diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index e238dac8..1566b451 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -10,7 +10,6 @@ final _log = AppLogger('LocalLibrary'); const _lastScannedAtKey = 'local_library_last_scanned_at'; -/// State for local library class LocalLibraryState { final List items; final bool isScanning; @@ -92,7 +91,6 @@ class LocalLibraryState { } } -/// Provider for local library state management class LocalLibraryNotifier extends Notifier { final LibraryDatabase _db = LibraryDatabase.instance; Timer? _progressTimer; @@ -120,7 +118,6 @@ class LocalLibraryNotifier extends Notifier { .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 { 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 { _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 = []; 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 { _log.w('Failed to save lastScannedAt: $e'); } - // Update state state = state.copyWith( items: items, isScanning: false, @@ -262,7 +253,6 @@ class LocalLibraryNotifier extends Notifier { Future clearLibrary() async { await _db.clearAll(); - // Clear lastScannedAt from SharedPreferences try { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_lastScannedAtKey); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 0d8991f7..7577ec20 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -292,7 +292,6 @@ void setUseAllFilesAccess(bool enabled) { _saveSettings(); } - // Local Library Settings void setLocalLibraryEnabled(bool enabled) { state = state.copyWith(localLibraryEnabled: enabled); _saveSettings(); diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index fff97f44..0f363c2f 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -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)))); diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 595cfef0..2c1e3088 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -103,7 +103,6 @@ class _ArtistScreenState extends ConsumerState { bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); - // Selection mode state bool _isSelectionMode = false; final Set _selectedAlbumIds = {}; bool _isFetchingDiscography = false; @@ -112,7 +111,6 @@ class _ArtistScreenState extends ConsumerState { 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> _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; return tracksList.map((t) => _parseTrack(t as Map)).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, 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( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index c8ab30b6..8a1af0c8 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -513,7 +513,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient Extension? currentSearchExtension; List 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( diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index fdf5c743..06bfc922 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -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)))); diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 4c599048..b95f31a4 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -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 { 'Dec', ]; -// Search functionality final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); String _searchQuery = ''; diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 3c99f7cf..a78d4848 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -117,7 +117,6 @@ class _LibrarySettingsPageState extends ConsumerState { return; } - // Check if folder exists if (!await Directory(libraryPath).exists()) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index de24ab6b..7a6e782f 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -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; diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index bfae0f3f..34f910eb 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -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 _jsonToDbRow(Map json) { return { 'id': json['id'], @@ -186,7 +182,6 @@ class LibraryDatabase { }; } - /// Convert DB row (snake_case) to JSON format (camelCase) Map _dbRowToJson(Map row) { return { 'id': row['id'], @@ -209,9 +204,8 @@ class LibraryDatabase { }; } - // ==================== CRUD Operations ==================== + // CRUD Operations - /// Insert or update a library item Future upsert(Map json) async { final db = await database; await db.insert( @@ -221,7 +215,6 @@ class LibraryDatabase { ); } - /// Batch insert multiple items Future upsertBatch(List> 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>> 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?> 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?> 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 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>> 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?> findExisting({ String? isrc, String? trackName, @@ -321,7 +308,6 @@ class LibraryDatabase { return null; } - /// Get all ISRCs as Set for fast in-memory lookup Future> 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> 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 deleteByPath(String filePath) async { final db = await database; await db.delete('library', where: 'file_path = ?', whereArgs: [filePath]); } - /// Delete by ID Future delete(String id) async { final db = await database; await db.delete('library', where: 'id = ?', whereArgs: [id]); } - /// Delete items where file no longer exists Future 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 clearAll() async { final db = await database; await db.delete('library'); _log.i('Cleared all library data'); } - /// Get total count Future 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>> 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 close() async { final db = await database; await db.close();