diff --git a/CLAUDE.md b/CLAUDE.md index 5c5d1e66..54fe7f6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,10 +66,199 @@ The application will use a central `config.json` file to store: - **COMPLETED**: Clean, functional button design with immediate accessibility **Active Work**: -- ⏳ Additional UI polish and user experience improvements -- ⏳ Matching engine development for cross-service track matching +- 🎯 **PRIORITY FEATURE**: Spotify Matched Download System - Advanced music organization with intelligent matching +- ⏳ Additional UI polish and user experience improvements +- ⏳ Enhanced matching engine development for cross-service track matching -**Current System Status**: All major download management functionality is working correctly. +**Current System Status**: All major download management functionality is working correctly. Ready for advanced Spotify integration features. + +--- + +# 🎯 MAJOR FEATURE: Spotify Matched Download & Organization System + +## 🎯 Feature Overview + +**Ultimate Goal**: Transform downloaded music files into a perfectly organized library with Spotify-accurate metadata and professional folder structure. + +**Core Concept**: Add "Matched Download" buttons (🎯) alongside regular download buttons that automatically: +1. Download the track using existing Soulseek integration +2. Intelligently match the track with Spotify's database for accurate metadata +3. Transfer and organize the file into a professional folder structure +4. Handle complex edge cases like remixes, compilations, and mixed-artist albums + +**Target Folder Structure**: +``` +Transfer/ +β”œβ”€β”€ Taylor Swift/ +β”‚ β”œβ”€β”€ Taylor Swift - 1989 (Taylor's Version)/ +β”‚ β”‚ β”œβ”€β”€ Taylor Swift - Shake It Off (Taylor's Version).flac +β”‚ β”‚ β”œβ”€β”€ Taylor Swift - Blank Space (Taylor's Version).flac +β”‚ β”‚ └── ... +β”‚ └── Taylor Swift - Folklore/ +β”‚ β”œβ”€β”€ Taylor Swift - Cardigan.flac +β”‚ └── ... +β”œβ”€β”€ Daft Punk/ +β”‚ └── Daft Punk - Random Access Memories/ +β”‚ β”œβ”€β”€ Daft Punk - Get Lucky (feat. Pharrell Williams).flac +β”‚ └── ... +└── Various Artists/ + └── Various Artists - Now That's What I Call Music 50/ + β”œβ”€β”€ Britney Spears - Toxic.flac + └── ... +``` + +## πŸš€ Implementation Phases + +### Phase 1: Foundation & Architecture ⏳ +**Status**: In Planning +**Deliverables**: +- βœ… Comprehensive specification document (SPOTIFY_MATCHING_SPEC.md) +- ⏳ Advanced metadata extraction system leveraging existing TrackResult/AlbumResult data +- ⏳ Sophisticated matching algorithms with multi-stage fallback logic +- ⏳ Professional UI architecture with responsive modal design + +### Phase 2: Core Services Development πŸ“‹ +**Status**: Pending Phase 1 +**Deliverables**: +- SpotifyMatchingService with intelligent search query generation +- FileOrganizationService with atomic file operations and conflict resolution +- MatchingEngine with confidence scoring and remix detection +- Enhanced error handling and logging systems + +### Phase 3: Professional UI Implementation 🎨 +**Status**: Pending Phase 2 +**Deliverables**: +- Responsive MatchingModal with proper spacing and layouts +- Real-time search interface with debouncing and progress indicators +- Confidence visualization and user feedback systems +- Accessibility features and keyboard navigation + +### Phase 4: Integration & Polish ⚑ +**Status**: Pending Phase 3 +**Deliverables**: +- 🎯 Matched download buttons integrated into existing UI components +- Download completion detection and automatic processing +- Album-level matching and batch processing capabilities +- Comprehensive testing and edge case handling + +## 🎡 Supported Download Types + +### 1. **Singles** (Primary Focus) +- Individual tracks from search results +- Most straightforward matching scenario +- Foundation for more complex matching logic + +### 2. **Albums** (Future Enhancement) +- Complete album downloads with track-by-track matching +- Handle mixed-artist compilations intelligently +- Detect and separate "fake albums" (user playlists disguised as albums) + +### 3. **Individual Album Tracks** (Future Enhancement) +- Tracks downloaded individually from within album results +- Inherit album context for better matching accuracy +- Maintain consistency with full album downloads + +## 🧠 Intelligent Matching System + +### Advanced Metadata Extraction +**Challenge**: Soulseek filenames are inconsistent and unreliable +**Solution**: Multi-source metadata aggregation +- **Primary**: Leverage existing `TrackResult.artist`, `TrackResult.title`, `TrackResult.album` fields +- **Secondary**: Enhanced filename parsing with regex patterns +- **Tertiary**: Directory path analysis for album context +- **Fallback**: Manual user input through search interface + +### Sophisticated Search Strategies +1. **Exact Match**: Artist + Title + Album (highest confidence) +2. **Partial Match**: Artist + Title (good confidence) +3. **Fuzzy Match**: Normalized strings with similarity scoring +4. **Remix Detection**: Extract remix artist from title patterns +5. **Manual Search**: User-driven fallback with suggestions + +### Confidence Scoring System +- **90-100%**: Exact metadata match, auto-proceed +- **75-89%**: High confidence, show for user confirmation +- **60-74%**: Medium confidence, require user review +- **Below 60%**: Low confidence, manual search required + +## πŸŽ›οΈ User Experience Flow + +### Seamless Workflow +1. **User clicks 🎯** on any track (single or within album) +2. **Download starts immediately** using existing proven download system +3. **Matching modal appears** with elegant, responsive design +4. **Automatic matching runs** in background with progress indication +5. **Results displayed** with confidence scores and preview information +6. **User confirms or refines** the match through intuitive interface +7. **File transferred atomically** to organized structure +8. **Success feedback** with option to open destination folder + +### Error Handling & Fallbacks +- **No automatic match**: Manual search interface with intelligent suggestions +- **Multiple high-confidence matches**: User selection with detailed comparison +- **No suitable matches found**: Option to proceed with regular download +- **File transfer errors**: Rollback mechanisms and detailed error reporting +- **Spotify API failures**: Graceful degradation with retry logic + +## πŸ”§ Technical Challenges & Solutions + +### Challenge 1: Inconsistent Soulseek Metadata +**Problem**: Filenames like "Track 01.mp3" or "asdjkfh - some song.flac" +**Solution**: Multi-stage extraction using existing TrackResult fields + enhanced parsing + +### Challenge 2: Remix Track Attribution +**Problem**: "Song Title (Artist Remix)" should match to remix artist, not original +**Solution**: Regex-based remix detection with artist extraction patterns + +### Challenge 3: Album vs Playlist Distinction +**Problem**: User playlists disguised as "albums" with mixed artists +**Solution**: Artist consistency analysis and intelligent categorization + +### Challenge 4: File Organization Conflicts +**Problem**: Duplicate files, naming conflicts, atomic operations +**Solution**: Professional file management with backup, rollback, and deduplication + +### Challenge 5: Spotify API Rate Limits +**Problem**: Search throttling and request failures +**Solution**: Intelligent caching, request batching, and exponential backoff + +## βš™οΈ Configuration & Settings + +### User Preferences +- **Auto-match threshold**: Minimum confidence for automatic processing +- **Folder naming patterns**: Customizable organization schemes +- **Transfer location**: Default destination directory +- **Conflict resolution**: Overwrite, rename, or skip duplicate files +- **Remix handling**: Original artist vs remix artist preference + +### Advanced Options +- **Search aggressiveness**: Number of search strategies to attempt +- **Metadata sources**: Priority order for information extraction +- **Quality preferences**: File format and bitrate handling +- **Cover art download**: Album artwork integration + +## 🎯 Success Criteria + +### Functional Requirements +- [ ] 95%+ success rate for popular tracks with clear metadata +- [ ] Graceful fallback handling for edge cases +- [ ] Sub-3-second matching time for typical searches +- [ ] Professional folder organization matching industry standards +- [ ] Zero data loss during file operations + +### User Experience Requirements +- [ ] Intuitive interface requiring minimal user training +- [ ] Clear progress indication and feedback +- [ ] Responsive design that adapts to different screen sizes +- [ ] Accessibility compliance for keyboard navigation +- [ ] Professional visual design matching existing application theme + +### Technical Requirements +- [ ] Robust error handling with detailed logging +- [ ] Atomic file operations with rollback capability +- [ ] Efficient memory usage during batch operations +- [ ] Integration with existing download queue system +- [ ] Maintainable code architecture for future enhancements ## Key Components Status diff --git a/SPOTIFY_MATCHING_SPEC.md b/SPOTIFY_MATCHING_SPEC.md new file mode 100644 index 00000000..2c18bf6e --- /dev/null +++ b/SPOTIFY_MATCHING_SPEC.md @@ -0,0 +1,907 @@ +# Spotify Matched Download System - Technical Specification + +## πŸ“‹ Document Purpose +This document provides comprehensive technical specifications for implementing the Spotify Matched Download System. It addresses the complexity of music metadata matching, file organization, and user interface design based on real-world challenges and requirements. + +--- + +## πŸ—οΈ System Architecture Overview + +### Core Components +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Spotify Matched Download System β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ UI Layer β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Matched Downloadβ”‚ β”‚ Matching Modal β”‚ β”‚ Progress Trackingβ”‚ β”‚ +β”‚ β”‚ Buttons β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Service Layer β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Metadata β”‚ β”‚ Spotify Matchingβ”‚ β”‚ File Organizationβ”‚ β”‚ +β”‚ β”‚ Extraction β”‚ β”‚ Service β”‚ β”‚ Service β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Integration Layer β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Soulseek β”‚ β”‚ Spotify β”‚ β”‚ File System β”‚ β”‚ +β”‚ β”‚ Client β”‚ β”‚ Client β”‚ β”‚ Manager β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## πŸ” Advanced Metadata Extraction System + +### Problem Statement +Soulseek metadata is notoriously inconsistent: +- Filenames: `"Track 01.mp3"`, `"asdjklh.flac"`, `"Artist - Song (Remix).mp3"` +- Directory paths: `"/User/Music/Random Folder/"` +- Missing or incorrect artist/title/album information + +### Solution: Multi-Tier Extraction Strategy + +#### Tier 1: Leverage Existing TrackResult Fields (PRIMARY) +```python +class EnhancedMetadataExtractor: + def extract_from_track_result(self, track: TrackResult) -> TrackMetadata: + """ + Primary extraction using existing TrackResult fields. + These are already parsed by soulseek_client.py + """ + return TrackMetadata( + artist=track.artist, # Already extracted! + title=track.title, # Already extracted! + album=track.album, # Already extracted! + track_number=track.track_number, # Already extracted! + filename=track.filename, + confidence=self.calculate_field_confidence(track) + ) +``` + +#### Tier 2: Enhanced Filename Parsing (SECONDARY) +```python +class AdvancedFilenameParser: + PATTERNS = [ + # Pattern: "Artist - Title" + r'^(?P.+?)\s*[-–—]\s*(?P.+?)(?:\s*\[(?P<extra>.*?)\])?(?:\s*\((?P<remix>.*?[Rr]emix.*?)\))?$', + + # Pattern: "01 - Artist - Title" + r'^(?P<track>\d+)\s*[-\.]\s*(?P<artist>.+?)\s*[-–—]\s*(?P<title>.+?)(?:\s*\((?P<remix>.*?)\))?$', + + # Pattern: "Artist - Album - Title" + r'^(?P<artist>.+?)\s*[-–—]\s*(?P<album>.+?)\s*[-–—]\s*(?P<title>.+?)$', + + # Pattern: "Title (Artist Remix)" + r'^(?P<title>.+?)\s*\((?P<remix_artist>.+?)\s+[Rr]emix\)$', + + # Pattern: "Album - Track - Title" + r'^(?P<album>.+?)\s*[-–—]\s*(?P<track>\d+)\s*[-–—]\s*(?P<title>.+?)$' + ] + + def parse_filename(self, filename: str) -> Optional[TrackMetadata]: + """Enhanced filename parsing with remix detection""" + base_name = self.clean_filename(filename) + + for pattern in self.PATTERNS: + match = re.match(pattern, base_name, re.IGNORECASE) + if match: + return self.create_metadata_from_match(match) + + return None +``` + +#### Tier 3: Directory Context Analysis (TERTIARY) +```python +class DirectoryContextAnalyzer: + def analyze_path_context(self, filepath: str) -> Optional[AlbumContext]: + """ + Extract album context from directory structure + Example: "/Music/Artist/Album (Year)/Track.flac" + """ + path_parts = Path(filepath).parts + + # Common patterns for album directories + album_patterns = [ + r'(?P<artist>.+?)\s*[-–—]\s*(?P<album>.+?)(?:\s*\((?P<year>\d{4})\))?', + r'(?P<album>.+?)(?:\s*\((?P<year>\d{4})\))?', + r'\[(?P<year>\d{4})\]\s*(?P<album>.+?)' + ] + + # Analyze parent directories for album info + for part in reversed(path_parts): + for pattern in album_patterns: + match = re.match(pattern, part, re.IGNORECASE) + if match: + return AlbumContext(**match.groupdict()) + + return None +``` + +--- + +## 🎡 Sophisticated Matching Algorithms + +### Multi-Stage Matching Pipeline + +#### Stage 1: Exact Match Strategy +```python +class ExactMatcher: + def find_exact_match(self, metadata: TrackMetadata) -> List[SpotifyMatch]: + """ + Highest confidence matching with exact metadata + """ + if not (metadata.artist and metadata.title): + return [] + + # Build exact search query + query_parts = [] + if metadata.artist: + query_parts.append(f'artist:"{metadata.artist}"') + if metadata.title: + query_parts.append(f'track:"{metadata.title}"') + if metadata.album: + query_parts.append(f'album:"{metadata.album}"') + + query = ' '.join(query_parts) + results = self.spotify_client.search_tracks(query, limit=5) + + return [SpotifyMatch(track, confidence=0.95) for track in results[:3]] +``` + +#### Stage 2: Fuzzy Match Strategy +```python +class FuzzyMatcher: + def find_fuzzy_matches(self, metadata: TrackMetadata) -> List[SpotifyMatch]: + """ + Similarity-based matching with confidence scoring + """ + # Normalize strings for comparison + normalized_artist = self.normalize_string(metadata.artist) + normalized_title = self.normalize_string(metadata.title) + + # Generate search variations + search_queries = [ + f"{normalized_artist} {normalized_title}", + f"{metadata.artist} {metadata.title}", # Original strings + f'"{normalized_artist}" "{normalized_title}"', # Quoted search + ] + + all_matches = [] + for query in search_queries: + results = self.spotify_client.search_tracks(query, limit=10) + for track in results: + confidence = self.calculate_similarity_confidence(metadata, track) + if confidence >= 0.6: # Minimum threshold + all_matches.append(SpotifyMatch(track, confidence)) + + # Deduplicate and sort by confidence + return self.deduplicate_matches(all_matches) + + def calculate_similarity_confidence(self, metadata: TrackMetadata, spotify_track: SpotifyTrack) -> float: + """ + Advanced confidence calculation with multiple factors + """ + # Artist similarity (weight: 40%) + artist_sim = self.string_similarity( + self.normalize_string(metadata.artist), + self.normalize_string(spotify_track.artists[0]) + ) + + # Title similarity (weight: 50%) + title_sim = self.string_similarity( + self.normalize_string(metadata.title), + self.normalize_string(spotify_track.name) + ) + + # Album similarity (weight: 10%) + album_sim = 0.0 + if metadata.album and spotify_track.album: + album_sim = self.string_similarity( + self.normalize_string(metadata.album), + self.normalize_string(spotify_track.album) + ) + + # Duration similarity bonus (weight: bonus +5%) + duration_bonus = 0.0 + if metadata.duration and spotify_track.duration_ms: + duration_diff = abs(metadata.duration - (spotify_track.duration_ms / 1000)) + if duration_diff <= 5: # Within 5 seconds + duration_bonus = 0.05 + + confidence = (artist_sim * 0.4) + (title_sim * 0.5) + (album_sim * 0.1) + duration_bonus + return min(confidence, 1.0) +``` + +#### Stage 3: Remix Detection & Handling +```python +class RemixMatcher: + REMIX_PATTERNS = [ + r'(?P<title>.+?)\s*\((?P<remix_artist>.+?)\s+[Rr]emix\)', + r'(?P<title>.+?)\s*\[(?P<remix_artist>.+?)\s+[Rr]emix\]', + r'(?P<title>.+?)\s*-\s*(?P<remix_artist>.+?)\s+[Rr]emix', + r'(?P<title>.+?)\s+\((?P<remix_artist>.+?)\s+[Vv]ersion\)', + ] + + def detect_remix(self, title: str) -> Optional[RemixInfo]: + """ + Extract remix information from track title + """ + for pattern in self.REMIX_PATTERNS: + match = re.search(pattern, title, re.IGNORECASE) + if match: + return RemixInfo( + original_title=match.group('title').strip(), + remix_artist=match.group('remix_artist').strip(), + is_remix=True + ) + return None + + def match_remix_track(self, metadata: TrackMetadata, remix_info: RemixInfo) -> List[SpotifyMatch]: + """ + Search for remix tracks with proper artist attribution + """ + search_queries = [ + f'artist:"{remix_info.remix_artist}" track:"{remix_info.original_title}"', + f'"{remix_info.remix_artist}" "{remix_info.original_title}" remix', + f'"{remix_info.original_title}" "{remix_info.remix_artist}"' + ] + + matches = [] + for query in search_queries: + results = self.spotify_client.search_tracks(query, limit=5) + for track in results: + # Prioritize tracks where remix artist is primary artist + if remix_info.remix_artist.lower() in [a.lower() for a in track.artists]: + confidence = 0.85 # High confidence for proper remix attribution + matches.append(SpotifyMatch(track, confidence, match_type="remix")) + + return matches +``` + +--- + +## 🎨 Professional UI Architecture + +### Responsive Modal Design + +#### Problem with Previous Implementation +- "Squished" content with poor spacing +- Inflexible layouts that didn't adapt to content +- Poor user experience with cramped interface + +#### Solution: Professional Modal Architecture +```python +class ResponsiveMatchingModal(QDialog): + """ + Professional modal with responsive design and proper spacing + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setup_responsive_ui() + + def setup_responsive_ui(self): + """ + Create responsive layout with proper spacing and sizing + """ + # Modal sizing - responsive to screen size + screen = QApplication.primaryScreen().geometry() + modal_width = min(900, int(screen.width() * 0.7)) # 70% of screen width, max 900px + modal_height = min(700, int(screen.height() * 0.8)) # 80% of screen height, max 700px + + self.resize(modal_width, modal_height) + self.setMinimumSize(600, 500) # Minimum usable size + + # Center on parent/screen + self.center_on_parent() + + # Main layout with proper margins + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(24, 24, 24, 24) # Generous margins + main_layout.setSpacing(20) # Proper spacing between sections + + # Create sections + self.create_header_section(main_layout) + self.create_progress_section(main_layout) + self.create_results_section(main_layout) + self.create_manual_search_section(main_layout) + self.create_action_buttons_section(main_layout) + + def create_header_section(self, parent_layout): + """ + Track information header with proper typography + """ + header_frame = QFrame() + header_frame.setStyleSheet(""" + QFrame { + background: rgba(30, 30, 30, 0.9); + border-radius: 12px; + padding: 20px; + } + """) + + header_layout = QVBoxLayout(header_frame) + header_layout.setSpacing(12) + + # Title with proper typography + title = QLabel("🎯 Spotify Track Matching") + title.setStyleSheet(""" + QLabel { + font-size: 22px; + font-weight: bold; + color: #1db954; + margin-bottom: 8px; + } + """) + + # Track info with readable formatting + track_info_layout = QGridLayout() + track_info_layout.setColumnStretch(1, 1) # Second column expands + + # Add track details with proper alignment + self.add_info_row(track_info_layout, 0, "Track:", self.track_metadata.title) + self.add_info_row(track_info_layout, 1, "Artist:", self.track_metadata.artist) + self.add_info_row(track_info_layout, 2, "Album:", self.track_metadata.album or "Unknown") + + header_layout.addWidget(title) + header_layout.addLayout(track_info_layout) + parent_layout.addWidget(header_frame) + + def create_results_section(self, parent_layout): + """ + Results section with proper scrolling and spacing + """ + results_frame = QFrame() + results_frame.setStyleSheet(""" + QFrame { + background: rgba(40, 40, 40, 0.9); + border-radius: 12px; + padding: 20px; + } + """) + + results_layout = QVBoxLayout(results_frame) + results_layout.setSpacing(16) + + # Section title + results_title = QLabel("🎡 Automatic Match Results") + results_title.setStyleSheet(""" + QLabel { + font-size: 18px; + font-weight: bold; + color: white; + margin-bottom: 10px; + } + """) + + # Scrollable results area + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarNever) + scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + scroll_area.setMinimumHeight(200) # Ensure minimum visible area + scroll_area.setStyleSheet(""" + QScrollArea { + border: none; + background: transparent; + } + QScrollBar:vertical { + background: rgba(60, 60, 60, 0.5); + width: 12px; + border-radius: 6px; + } + QScrollBar::handle:vertical { + background: rgba(29, 185, 84, 0.8); + border-radius: 6px; + min-height: 20px; + } + """) + + # Results container + self.results_container = QWidget() + self.results_layout = QVBoxLayout(self.results_container) + self.results_layout.setSpacing(12) # Proper spacing between result items + self.results_layout.setContentsMargins(0, 0, 0, 0) + + scroll_area.setWidget(self.results_container) + + results_layout.addWidget(results_title) + results_layout.addWidget(scroll_area, 1) # Expand to fill space + + parent_layout.addWidget(results_frame, 1) # Allow results section to expand +``` + +#### Individual Result Item Design +```python +class SpotifyMatchResultItem(QFrame): + """ + Individual Spotify match result with professional styling + """ + + def __init__(self, spotify_track: SpotifyTrack, confidence: float, parent=None): + super().__init__(parent) + self.spotify_track = spotify_track + self.confidence = confidence + self.setup_professional_ui() + + def setup_professional_ui(self): + """ + Create professional result item with proper spacing + """ + self.setFixedHeight(100) # Consistent height for all items + self.setStyleSheet(""" + QFrame { + background: rgba(50, 50, 50, 0.8); + border: 1px solid rgba(80, 80, 80, 0.6); + border-radius: 10px; + margin: 4px 0px; + } + QFrame:hover { + background: rgba(60, 60, 60, 0.9); + border-color: rgba(29, 185, 84, 0.8); + } + """) + + layout = QHBoxLayout(self) + layout.setContentsMargins(16, 12, 16, 12) # Proper margins + layout.setSpacing(16) # Good spacing between elements + + # Left section: Track info + info_layout = QVBoxLayout() + info_layout.setSpacing(4) + + # Track title + title_label = QLabel(spotify_track.name) + title_label.setStyleSheet(""" + QLabel { + font-size: 16px; + font-weight: bold; + color: white; + } + """) + title_label.setWordWrap(True) + + # Artist and album + artist_text = ", ".join(spotify_track.artists) + details_label = QLabel(f"by {artist_text}") + details_label.setStyleSheet(""" + QLabel { + font-size: 13px; + color: rgba(255, 255, 255, 0.8); + } + """) + + album_label = QLabel(f"from {spotify_track.album}") + album_label.setStyleSheet(""" + QLabel { + font-size: 12px; + color: rgba(255, 255, 255, 0.6); + } + """) + + info_layout.addWidget(title_label) + info_layout.addWidget(details_label) + info_layout.addWidget(album_label) + + # Right section: Confidence and select button + right_layout = QVBoxLayout() + right_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Confidence indicator + confidence_widget = self.create_confidence_widget() + + # Select button + select_button = QPushButton("Select This Track") + select_button.setStyleSheet(""" + QPushButton { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 rgba(29, 185, 84, 0.9), + stop:1 rgba(25, 156, 71, 0.9)); + color: white; + border: none; + border-radius: 6px; + padding: 8px 16px; + font-weight: bold; + font-size: 13px; + } + QPushButton:hover { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 rgba(32, 200, 90, 1.0), + stop:1 rgba(28, 170, 76, 1.0)); + } + """) + select_button.clicked.connect(self.on_select_clicked) + + right_layout.addWidget(confidence_widget) + right_layout.addStretch() + right_layout.addWidget(select_button) + + # Assembly + layout.addLayout(info_layout, 1) # Expand info section + layout.addLayout(right_layout) + + def create_confidence_widget(self) -> QWidget: + """ + Create professional confidence indicator + """ + confidence_widget = QFrame() + confidence_widget.setFixedSize(60, 60) + + # Color based on confidence level + if self.confidence >= 0.9: + color = "#28a745" # Green + text_color = "white" + elif self.confidence >= 0.75: + color = "#ffc107" # Yellow + text_color = "black" + elif self.confidence >= 0.6: + color = "#fd7e14" # Orange + text_color = "white" + else: + color = "#dc3545" # Red + text_color = "white" + + confidence_widget.setStyleSheet(f""" + QFrame {{ + background: {color}; + border-radius: 30px; + border: 2px solid rgba(255, 255, 255, 0.2); + }} + """) + + layout = QVBoxLayout(confidence_widget) + layout.setContentsMargins(0, 0, 0, 0) + + percentage_label = QLabel(f"{int(self.confidence * 100)}%") + percentage_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + percentage_label.setStyleSheet(f""" + QLabel {{ + color: {text_color}; + font-size: 14px; + font-weight: bold; + }} + """) + + layout.addWidget(percentage_label) + + return confidence_widget +``` + +--- + +## πŸ“ Professional File Organization System + +### Atomic File Operations +```python +class AtomicFileOrganizer: + """ + Professional file organization with rollback capability + """ + + def __init__(self, transfer_base_path: str = "Transfer"): + self.transfer_base_path = Path(transfer_base_path) + self.operation_log = [] # Track operations for rollback + + def organize_file(self, source_path: str, spotify_track: SpotifyTrack) -> FileOrganizationResult: + """ + Atomically organize file with full rollback capability + """ + try: + # Phase 1: Validation + source_file = Path(source_path) + if not source_file.exists(): + return FileOrganizationResult( + success=False, + error="Source file does not exist", + source_path=source_path + ) + + # Phase 2: Destination planning + destination_path = self.calculate_destination_path(spotify_track, source_file.suffix) + + # Phase 3: Conflict resolution + final_destination = self.resolve_conflicts(destination_path) + + # Phase 4: Atomic operation + return self.perform_atomic_move(source_file, final_destination) + + except Exception as e: + return FileOrganizationResult( + success=False, + error=f"Organization failed: {str(e)}", + source_path=source_path + ) + + def calculate_destination_path(self, spotify_track: SpotifyTrack, file_extension: str) -> Path: + """ + Calculate organized file path following professional naming conventions + """ + # Sanitize names for filesystem compatibility + artist_name = self.sanitize_filename(spotify_track.artists[0]) + album_name = self.sanitize_filename(spotify_track.album) + track_name = self.sanitize_filename(spotify_track.name) + + # Handle multi-artist tracks + if len(spotify_track.artists) > 1: + primary_artist = spotify_track.artists[0] + # Keep featured artists in track name + if "feat." in track_name.lower() or "featuring" in track_name.lower(): + final_track_name = f"{primary_artist} - {track_name}" + else: + featured_artists = ", ".join(spotify_track.artists[1:]) + final_track_name = f"{primary_artist} - {track_name} (feat. {featured_artists})" + else: + final_track_name = f"{artist_name} - {track_name}" + + # Create path structure + artist_folder = artist_name + album_folder = f"{artist_name} - {album_name}" + filename = f"{final_track_name}{file_extension}" + + return self.transfer_base_path / artist_folder / album_folder / filename + + def resolve_conflicts(self, destination_path: Path) -> Path: + """ + Handle file naming conflicts professionally + """ + if not destination_path.exists(): + return destination_path + + # Generate unique filename + base_path = destination_path.parent / destination_path.stem + extension = destination_path.suffix + counter = 1 + + while True: + new_path = Path(f"{base_path} ({counter}){extension}") + if not new_path.exists(): + return new_path + counter += 1 + + # Safety limit + if counter > 100: + raise Exception("Too many file conflicts") + + def perform_atomic_move(self, source: Path, destination: Path) -> FileOrganizationResult: + """ + Perform atomic file move with backup and rollback + """ + operation_id = str(uuid.uuid4()) + + try: + # Ensure destination directory exists + destination.parent.mkdir(parents=True, exist_ok=True) + + # Create backup if destination exists + backup_path = None + if destination.exists(): + backup_path = destination.with_suffix(f".backup_{operation_id}") + shutil.copy2(destination, backup_path) + self.operation_log.append({ + 'operation_id': operation_id, + 'type': 'backup', + 'path': backup_path + }) + + # Perform the move + shutil.move(str(source), str(destination)) + self.operation_log.append({ + 'operation_id': operation_id, + 'type': 'move', + 'source': str(source), + 'destination': str(destination) + }) + + # Cleanup backup on success + if backup_path and backup_path.exists(): + backup_path.unlink() + + return FileOrganizationResult( + success=True, + source_path=str(source), + destination_path=str(destination), + operation_id=operation_id + ) + + except Exception as e: + # Rollback on failure + self.rollback_operation(operation_id) + return FileOrganizationResult( + success=False, + error=f"File move failed: {str(e)}", + source_path=str(source) + ) + + def sanitize_filename(self, name: str) -> str: + """ + Sanitize filename for cross-platform compatibility + """ + # Remove/replace invalid characters + invalid_chars = r'<>:"/\|?*' + for char in invalid_chars: + name = name.replace(char, '') + + # Handle special cases + name = name.replace('..', '.') # Double dots + name = re.sub(r'\s+', ' ', name) # Multiple spaces + name = name.strip(' .') # Leading/trailing spaces and dots + + # Length limit + if len(name) > 200: + name = name[:200].rsplit(' ', 1)[0] # Break at word boundary + + return name or "Unknown" # Fallback for empty names +``` + +--- + +## πŸ”„ Integration with Existing Download System + +### Download Completion Detection +```python +class DownloadCompletionMonitor: + """ + Monitor download completions and trigger matching process + """ + + def __init__(self, download_manager, matching_service): + self.download_manager = download_manager + self.matching_service = matching_service + self.pending_matches = {} # Track matched downloads + + def register_matched_download(self, search_result, track_metadata): + """ + Register a download for post-completion matching + """ + download_id = self.generate_download_id(search_result) + self.pending_matches[download_id] = { + 'search_result': search_result, + 'track_metadata': track_metadata, + 'timestamp': time.time() + } + + def on_download_completed(self, download_item): + """ + Handle download completion and trigger matching if needed + """ + download_id = self.generate_download_id(download_item.search_result) + + if download_id in self.pending_matches: + # This was a matched download - trigger matching process + match_info = self.pending_matches[download_id] + self.trigger_post_download_matching(download_item, match_info) + del self.pending_matches[download_id] + + def trigger_post_download_matching(self, download_item, match_info): + """ + Start matching process after download completion + """ + # Update track metadata with actual download path + track_metadata = match_info['track_metadata'] + track_metadata.file_path = download_item.local_path + + # Show matching modal + modal = MatchingModal( + matching_service=self.matching_service, + track_metadata=track_metadata, + download_path=download_item.local_path + ) + modal.show() +``` + +--- + +## πŸ§ͺ Testing Strategy + +### Unit Tests +```python +class TestMetadataExtraction: + """Test metadata extraction with real-world examples""" + + def test_common_filename_patterns(self): + test_cases = [ + ("Artist - Song.mp3", {"artist": "Artist", "title": "Song"}), + ("01 - Artist - Song.flac", {"track": 1, "artist": "Artist", "title": "Song"}), + ("Song (Artist Remix).mp3", {"title": "Song", "remix_artist": "Artist"}), + ("Artist - Album - Song.mp3", {"artist": "Artist", "album": "Album", "title": "Song"}), + ] + + extractor = AdvancedFilenameParser() + for filename, expected in test_cases: + result = extractor.parse_filename(filename) + assert result.artist == expected.get("artist") + assert result.title == expected.get("title") + +class TestMatchingAlgorithms: + """Test matching accuracy with known examples""" + + def test_exact_matches(self): + """Test exact matching with perfect metadata""" + pass + + def test_fuzzy_matches(self): + """Test fuzzy matching with slight variations""" + pass + + def test_remix_detection(self): + """Test remix detection and proper artist attribution""" + pass + +class TestFileOrganization: + """Test file organization and conflict resolution""" + + def test_atomic_operations(self): + """Test atomic file moves with rollback""" + pass + + def test_conflict_resolution(self): + """Test handling of duplicate files""" + pass + + def test_cross_platform_compatibility(self): + """Test filename sanitization across platforms""" + pass +``` + +### Integration Tests +```python +class TestEndToEndWorkflow: + """Test complete matched download workflow""" + + def test_single_track_workflow(self): + """Test complete single track matched download""" + pass + + def test_error_handling_workflow(self): + """Test error scenarios and fallbacks""" + pass + + def test_ui_responsiveness(self): + """Test UI behavior under various conditions""" + pass +``` + +--- + +## πŸ“Š Performance Considerations + +### Optimization Strategies +1. **Caching**: Cache Spotify search results to avoid duplicate API calls +2. **Batch Processing**: Group multiple searches for efficiency +3. **Lazy Loading**: Load UI elements as needed +4. **Background Processing**: Perform heavy operations in separate threads +5. **Memory Management**: Proper cleanup of modal dialogs and threads + +### Monitoring & Metrics +- Track matching success rates +- Monitor API response times +- Log file organization errors +- Measure user interaction patterns + +--- + +## 🎯 Implementation Priorities + +### Phase 1: Core Foundation +1. βœ… Enhanced metadata extraction system +2. βœ… Basic matching algorithms +3. βœ… File organization framework +4. βœ… Professional UI architecture + +### Phase 2: Advanced Features +1. Remix detection and handling +2. Confidence scoring system +3. Error handling and rollback +4. Performance optimizations + +### Phase 3: Integration & Polish +1. Download system integration +2. Comprehensive testing +3. User experience refinements +4. Documentation and deployment + +This specification provides a comprehensive foundation for implementing a professional-grade Spotify matching system that addresses real-world complexity and user experience requirements. \ No newline at end of file