current job
This commit is contained in:
parent
bed02b2faf
commit
b9b5bdb65d
2 changed files with 1099 additions and 3 deletions
193
CLAUDE.md
193
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
|
- **COMPLETED**: Clean, functional button design with immediate accessibility
|
||||||
|
|
||||||
**Active Work**:
|
**Active Work**:
|
||||||
|
- 🎯 **PRIORITY FEATURE**: Spotify Matched Download System - Advanced music organization with intelligent matching
|
||||||
- ⏳ Additional UI polish and user experience improvements
|
- ⏳ Additional UI polish and user experience improvements
|
||||||
- ⏳ Matching engine development for cross-service track matching
|
- ⏳ 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
|
## 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