907 lines
No EOL
33 KiB
Markdown
907 lines
No EOL
33 KiB
Markdown
# 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. |