current job
This commit is contained in:
parent
bed02b2faf
commit
b9b5bdb65d
2 changed files with 1099 additions and 3 deletions
195
CLAUDE.md
195
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
|
||||
|
||||
|
|
|
|||
907
SPOTIFY_MATCHING_SPEC.md
Normal file
907
SPOTIFY_MATCHING_SPEC.md
Normal file
|
|
@ -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<artist>.+?)\s*[-–—]\s*(?P<title>.+?)(?:\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.
|
||||
Loading…
Reference in a new issue