diff --git a/services/beat_matching.py b/services/beat_matching.py new file mode 100644 index 00000000..d8eab05a --- /dev/null +++ b/services/beat_matching.py @@ -0,0 +1,381 @@ +# swingmusic/services/beat_matching.py +import numpy as np +import librosa +from typing import Dict, List, Tuple, Optional +from dataclasses import dataclass +import logging + +logger = logging.getLogger(__name__) + +@dataclass +class BeatInfo: + """Beat information for a track""" + tempo: float + beat_frames: np.ndarray + beat_times: np.ndarray + downbeats: np.ndarray + key: str + energy_curve: np.ndarray + +@dataclass +class BeatMatch: + """Beat matching result between two tracks""" + compatibility_score: float + tempo_ratio: float + key_compatibility: str + optimal_mix_point: float + transition_type: str + energy_match: float + +class BeatMatcher: + """Advanced beat matching engine for DJ transitions""" + + def __init__(self): + self.sample_rate = 22050 + self.hop_length = 512 + self.key_compatibility_matrix = self._build_key_compatibility_matrix() + + def _build_key_compatibility_matrix(self) -> Dict[str, Dict[str, float]]: + """Build harmonic key compatibility matrix""" + # Camelot wheel compatibility + compatibility = { + '1A': {'1A': 1.0, '12A': 0.9, '2A': 0.9, '1B': 0.8, '12B': 0.8}, + '2A': {'2A': 1.0, '1A': 0.9, '3A': 0.9, '2B': 0.8, '1B': 0.8}, + '3A': {'3A': 1.0, '2A': 0.9, '4A': 0.9, '3B': 0.8, '2B': 0.8}, + '4A': {'4A': 1.0, '3A': 0.9, '5A': 0.9, '4B': 0.8, '3B': 0.8}, + '5A': {'5A': 1.0, '4A': 0.9, '6A': 0.9, '5B': 0.8, '4B': 0.8}, + '6A': {'6A': 1.0, '5A': 0.9, '7A': 0.9, '6B': 0.8, '5B': 0.8}, + '7A': {'7A': 1.0, '6A': 0.9, '8A': 0.9, '7B': 0.8, '6B': 0.8}, + '8A': {'8A': 1.0, '7A': 0.9, '9A': 0.9, '8B': 0.8, '7B': 0.8}, + '9A': {'9A': 1.0, '8A': 0.9, '10A': 0.9, '9B': 0.8, '8B': 0.8}, + '10A': {'10A': 1.0, '9A': 0.9, '11A': 0.9, '10B': 0.8, '9B': 0.8}, + '11A': {'11A': 1.0, '10A': 0.9, '12A': 0.9, '11B': 0.8, '10B': 0.8}, + '12A': {'12A': 1.0, '11A': 0.9, '1A': 0.9, '12B': 0.8, '11B': 0.8}, + '1B': {'1B': 1.0, '12B': 0.9, '2B': 0.9, '1A': 0.8, '12A': 0.8}, + '2B': {'2B': 1.0, '1B': 0.9, '3B': 0.9, '2A': 0.8, '1A': 0.8}, + '3B': {'3B': 1.0, '2B': 0.9, '4B': 0.9, '3A': 0.8, '2A': 0.8}, + '4B': {'4B': 1.0, '3B': 0.9, '5B': 0.9, '4A': 0.8, '3A': 0.8}, + '5B': {'5B': 1.0, '4B': 0.9, '6B': 0.9, '5A': 0.8, '4A': 0.8}, + '6B': {'6B': 1.0, '5B': 0.9, '7B': 0.9, '6A': 0.8, '5A': 0.8}, + '7B': {'7B': 1.0, '6B': 0.9, '8B': 0.9, '7A': 0.8, '6A': 0.8}, + '8B': {'8B': 1.0, '7B': 0.9, '9B': 0.9, '8A': 0.8, '7A': 0.8}, + '9B': {'9B': 1.0, '8B': 0.9, '10B': 0.9, '9A': 0.8, '8A': 0.8}, + '10B': {'10B': 1.0, '9B': 0.9, '11B': 0.9, '10A': 0.8, '9A': 0.8}, + '11B': {'11B': 1.0, '10B': 0.9, '12B': 0.9, '11A': 0.8, '10A': 0.8}, + '12B': {'12B': 1.0, '11B': 0.9, '1B': 0.9, '12A': 0.8, '11A': 0.8} + } + return compatibility + + async def analyze_beats(self, audio_path: str) -> BeatInfo: + """Analyze beats and tempo from audio file""" + try: + # Load audio + y, sr = librosa.load(audio_path, sr=self.sample_rate) + + # Extract tempo and beat frames + tempo, beat_frames = librosa.beat.beat_track( + y=y, sr=sr, hop_length=self.hop_length + ) + + # Convert frames to time + beat_times = librosa.frames_to_time( + beat_frames, sr=sr, hop_length=self.hop_length + ) + + # Detect downbeats (first beat of each measure) + downbeats = self._detect_downbeats(y, sr, beat_frames) + + # Extract key + key = await self._extract_key(y, sr) + + # Calculate energy curve + energy_curve = self._calculate_energy_curve(y, beat_frames) + + return BeatInfo( + tempo=float(tempo), + beat_frames=beat_frames, + beat_times=beat_times, + downbeats=downbeats, + key=key, + energy_curve=energy_curve + ) + + except Exception as e: + logger.error(f"Beat analysis failed for {audio_path}: {e}") + raise + + def _detect_downbeats(self, y: np.ndarray, sr: int, beat_frames: np.ndarray) -> np.ndarray: + """Detect downbeats using harmonic and percussive separation""" + # Separate harmonic and percussive + y_harmonic = librosa.effects.harmonic(y) + y_percussive = librosa.effects.percussive(y) + + # Calculate onset strength + harmonic_onset = librosa.onset.onset_strength( + y=y_harmonic, sr=sr, hop_length=self.hop_length + ) + percussive_onset = librosa.onset.onset_strength( + y=y_percussive, sr=sr, hop_length=self.hop_length + ) + + # Combine onset strengths + combined_onset = harmonic_onset + percussive_onset + + # Find peaks at beat positions + beat_onset_strength = combined_onset[beat_frames] + + # Detect downbeats (peaks in onset strength) + downbeat_indices = [] + for i in range(1, len(beat_onset_strength) - 1): + if (beat_onset_strength[i] > beat_onset_strength[i-1] and + beat_onset_strength[i] > beat_onset_strength[i+1]): + downbeat_indices.append(i) + + return beat_frames[downbeat_indices] if downbeat_indices else beat_frames[::4] # Assume 4/4 time + + async def _extract_key(self, y: np.ndarray, sr: int) -> str: + """Extract musical key using chroma features""" + # Extract chroma features + chroma = librosa.feature.chroma_stft(y=y, sr=sr, hop_length=self.hop_length) + + # Average chroma across time + chroma_mean = np.mean(chroma, axis=1) + + # Determine key profile (simplified) + # Map to Camelot notation + key_profiles = { + 'C': '8B', 'C#': '3B', 'D': '10B', 'D#': '5B', + 'E': '12B', 'F': '7B', 'F#': '2B', 'G': '9B', + 'G#': '4B', 'A': '11B', 'A#': '6B', 'B': '1B', + 'Cm': '8A', 'C#m': '3A', 'Dm': '10A', 'D#m': '5A', + 'Em': '12A', 'Fm': '7A', 'F#m': '2A', 'Gm': '9A', + 'G#m': '4A', 'Am': '11A', 'A#m': '6A', 'Bm': '1A' + } + + # Find best matching key (simplified - would need more sophisticated analysis) + max_chroma_idx = np.argmax(chroma_mean) + key_names = list(key_profiles.keys()) + detected_key = key_names[max_chroma_idx % len(key_names)] + + return key_profiles.get(detected_key, '8A') # Default to C major + + def _calculate_energy_curve(self, y: np.ndarray, beat_frames: np.ndarray) -> np.ndarray: + """Calculate energy curve for each beat""" + energy_curve = [] + + for i in range(len(beat_frames) - 1): + start_frame = beat_frames[i] + end_frame = beat_frames[i + 1] if i + 1 < len(beat_frames) else len(y) + + # Calculate RMS energy for this beat + beat_segment = y[start_frame:end_frame] + energy = np.sqrt(np.mean(beat_segment ** 2)) + energy_curve.append(energy) + + return np.array(energy_curve) + + async def calculate_beat_match(self, track1_beats: BeatInfo, track2_beats: BeatInfo) -> BeatMatch: + """Calculate beat matching compatibility between two tracks""" + try: + # Tempo compatibility + tempo_ratio = track2_beats.tempo / track1_beats.tempo + tempo_compatibility = self._calculate_tempo_compatibility(tempo_ratio) + + # Key compatibility + key_compatibility_score = self._calculate_key_compatibility( + track1_beats.key, track2_beats.key + ) + + # Energy matching + energy_match = self._calculate_energy_compatibility( + track1_beats.energy_curve, track2_beats.energy_curve + ) + + # Overall compatibility + compatibility_score = ( + tempo_compatibility * 0.4 + + key_compatibility_score * 0.3 + + energy_match * 0.3 + ) + + # Determine optimal transition type + transition_type = self._determine_transition_type( + tempo_compatibility, key_compatibility_score, energy_match + ) + + # Find optimal mix point + optimal_mix_point = self._find_optimal_mix_point( + track1_beats, track2_beats, transition_type + ) + + return BeatMatch( + compatibility_score=compatibility_score, + tempo_ratio=tempo_ratio, + key_compatibility=f"{track1_beats.key} → {track2_beats.key}", + optimal_mix_point=optimal_mix_point, + transition_type=transition_type, + energy_match=energy_match + ) + + except Exception as e: + logger.error(f"Beat matching calculation failed: {e}") + raise + + def _calculate_tempo_compatibility(self, tempo_ratio: float) -> float: + """Calculate tempo compatibility score""" + # Perfect match (same tempo) + if 0.98 <= tempo_ratio <= 1.02: + return 1.0 + + # Harmonic ratios (2x, 0.5x) + if 0.49 <= tempo_ratio <= 0.51 or 1.98 <= tempo_ratio <= 2.02: + return 0.8 + + # Close match (within 10%) + if 0.9 <= tempo_ratio <= 1.1: + return 0.7 + + # Acceptable range (within 20%) + if 0.8 <= tempo_ratio <= 1.2: + return 0.5 + + # Poor compatibility + return 0.2 + + def _calculate_key_compatibility(self, key1: str, key2: str) -> float: + """Calculate harmonic key compatibility""" + if key1 in self.key_compatibility_matrix and key2 in self.key_compatibility_matrix[key1]: + return self.key_compatibility_matrix[key1][key2] + return 0.1 # Very low compatibility for unknown keys + + def _calculate_energy_compatibility(self, energy1: np.ndarray, energy2: np.ndarray) -> float: + """Calculate energy curve compatibility""" + if len(energy1) == 0 or len(energy2) == 0: + return 0.5 + + # Normalize energy curves + energy1_norm = (energy1 - np.min(energy1)) / (np.max(energy1) - np.min(energy1)) + energy2_norm = (energy2 - np.min(energy2)) / (np.max(energy2) - np.min(energy2)) + + # Calculate correlation + min_length = min(len(energy1_norm), len(energy2_norm)) + correlation = np.corrcoef(energy1_norm[:min_length], energy2_norm[:min_length])[0, 1] + + if np.isnan(correlation): + return 0.5 + + return (correlation + 1) / 2 # Normalize to 0-1 range + + def _determine_transition_type(self, tempo_comp: float, key_comp: float, energy_comp: float) -> str: + """Determine optimal transition type""" + overall_score = (tempo_comp + key_comp + energy_comp) / 3 + + if overall_score >= 0.8: + return "perfect_blend" + elif overall_score >= 0.6: + return "smooth_transition" + elif overall_score >= 0.4: + return "crossfade" + elif key_comp >= 0.7: + return "harmonic_mix" + else: + return "cut" + + def _find_optimal_mix_point(self, track1_beats: BeatInfo, track2_beats: BeatInfo, + transition_type: str) -> float: + """Find optimal mix point in track 1 (0-1, where 1 is end)""" + if transition_type == "perfect_blend": + # Mix near the end for perfect blend + return 0.85 + elif transition_type == "smooth_transition": + # Earlier mix point for smooth transition + return 0.75 + elif transition_type == "crossfade": + # Standard crossfade point + return 0.9 + elif transition_type == "harmonic_mix": + # Mix at downbeat for harmonic compatibility + if len(track1_beats.downbeats) > 0: + last_downbeat_ratio = track1_beats.downbeats[-1] / len(track1_beats.beat_frames) + return min(last_downbeat_ratio + 0.1, 0.9) + return 0.8 + else: # cut + # Quick cut near the end + return 0.95 + + async def create_beat_sync_transition(self, track1_path: str, track2_path: str, + beat_match: BeatMatch) -> Dict: + """Create beat-synchronized transition parameters""" + try: + # Analyze both tracks + track1_beats = await self.analyze_beats(track1_path) + track2_beats = await self.analyze_beats(track2_path) + + # Calculate transition parameters + transition_start = beat_match.optimal_mix_point + transition_duration = self._calculate_transition_duration( + beat_match.transition_type, track1_beats.tempo + ) + + # Calculate tempo adjustment + tempo_adjustment = 1.0 / beat_match.tempo_ratio if beat_match.tempo_ratio != 1.0 else 1.0 + + return { + 'transition_type': beat_match.transition_type, + 'start_point': transition_start, + 'duration': transition_duration, + 'tempo_adjustment': tempo_adjustment, + 'key_change_needed': beat_match.key_compatibility, + 'energy_ramp': self._calculate_energy_ramp( + track1_beats.energy_curve, track2_beats.energy_curve, + transition_start, transition_duration + ), + 'beat_markers': { + 'track1_last_beat': transition_start, + 'track2_first_beat': 0.0, + 'sync_point': transition_start + transition_duration / 2 + } + } + + except Exception as e: + logger.error(f"Beat sync transition creation failed: {e}") + raise + + def _calculate_transition_duration(self, transition_type: str, tempo: float) -> float: + """Calculate transition duration based on type and tempo""" + beat_duration = 60.0 / tempo + + if transition_type == "perfect_blend": + return beat_duration * 16 # 16 bars + elif transition_type == "smooth_transition": + return beat_duration * 8 # 8 bars + elif transition_type == "crossfade": + return beat_duration * 4 # 4 bars + elif transition_type == "harmonic_mix": + return beat_duration * 8 # 8 bars for harmonic transition + else: # cut + return beat_duration * 1 # 1 bar quick cut + + def _calculate_energy_ramp(self, energy1: np.ndarray, energy2: np.ndarray, + start_point: float, duration: float) -> np.ndarray: + """Calculate energy ramp for smooth transition""" + # Get energy values around transition point + start_idx = int(len(energy1) * start_point) + end_idx = min(start_idx + int(duration * len(energy1)), len(energy1)) + + if start_idx >= len(energy1): + return np.array([0.5]) # Default energy + + track1_energy = energy1[start_idx:end_idx] if start_idx < len(energy1) else np.array([energy1[-1]]) + + # Create smooth ramp from track1 energy to track2 energy + track2_start_energy = energy2[0] if len(energy2) > 0 else 0.5 + + if len(track1_energy) == 0: + return np.linspace(0.5, track2_start_energy, 10) + + ramp_points = max(len(track1_energy), 10) + energy_ramp = np.linspace(track1_energy[-1] if len(track1_energy) > 0 else 0.5, + track2_start_energy, ramp_points) + + return energy_ramp diff --git a/services/key_harmony.py b/services/key_harmony.py new file mode 100644 index 00000000..3b20e467 --- /dev/null +++ b/services/key_harmony.py @@ -0,0 +1,504 @@ +# swingmusic/services/key_harmony.py +import numpy as np +import librosa +from typing import Dict, List, Tuple, Optional +from dataclasses import dataclass +import logging + +logger = logging.getLogger(__name__) + +@dataclass +class KeyInfo: + """Musical key information""" + key: str + mode: str # 'major' or 'minor' + camelot: str # Camelot wheel notation + confidence: float + chroma_profile: np.ndarray + tonic_frequency: float + +@dataclass +class HarmonyAnalysis: + """Harmonic relationship analysis between two keys""" + relationship_type: str + compatibility_score: float + transition_suggestions: List[str] + energy_change: str + emotional_impact: str + +class KeyHarmonyDetector: + """Advanced key harmony detection for musical transitions""" + + def __init__(self): + self.sample_rate = 22050 + self.hop_length = 512 + self.key_templates = self._build_key_templates() + self.harmony_rules = self._build_harmony_rules() + + def _build_key_templates(self) -> Dict[str, np.ndarray]: + """Build chroma templates for all 24 keys""" + # 12 major and 12 minor key templates + keys = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + templates = {} + + # Major key templates (Ionian mode) + major_intervals = [0, 2, 4, 5, 7, 9, 11] # Major scale intervals + for i, key in enumerate(keys): + template = np.zeros(12) + for interval in major_intervals: + template[(i + interval) % 12] = 1.0 + templates[f"{key}_major"] = template + + # Minor key templates (Aeolian mode) + minor_intervals = [0, 2, 3, 5, 7, 8, 10] # Natural minor scale intervals + for i, key in enumerate(keys): + template = np.zeros(12) + for interval in minor_intervals: + template[(i + interval) % 12] = 1.0 + templates[f"{key}_minor"] = template + + return templates + + def _build_harmony_rules(self) -> Dict[str, Dict[str, float]]: + """Build harmonic compatibility rules""" + return { + 'perfect_match': {'score': 1.0, 'energy': 'stable', 'emotion': 'seamless'}, + 'relative_major_minor': {'score': 0.9, 'energy': 'gentle_rise', 'emotion': 'uplifting'}, + 'parallel_major_minor': {'score': 0.8, 'energy': 'dramatic_shift', 'emotion': 'intense'}, + 'dominant': {'score': 0.7, 'energy': 'building_tension', 'emotion': 'anticipating'}, + 'subdominant': {'score': 0.7, 'energy': 'resolving', 'emotion': 'calming'}, + 'chromatic_mediant': {'score': 0.6, 'energy': 'surprising', 'emotion': 'dreamy'}, + 'change_mode': {'score': 0.5, 'energy': 'noticeable_shift', 'emotion': 'reflective'}, + 'tritone_substitution': {'score': 0.4, 'energy': 'dramatic', 'emotion': 'tense'}, + 'moderate_dissonance': {'score': 0.3, 'energy': 'uncomfortable', 'emotion': 'edgy'}, + 'strong_dissonance': {'score': 0.1, 'energy': 'jarring', 'emotion': 'chaotic'} + } + + async def analyze_key(self, audio_path: str) -> KeyInfo: + """Analyze the musical key of an audio file""" + try: + # Load audio + y, sr = librosa.load(audio_path, sr=self.sample_rate) + + # Extract chroma features + chroma = librosa.feature.chroma_stft(y=y, sr=sr, hop_length=self.hop_length) + + # Average chroma across time + chroma_mean = np.mean(chroma, axis=1) + + # Normalize chroma + chroma_norm = chroma_mean / np.sum(chroma_mean) + + # Find best matching key + best_key, confidence = self._match_key_template(chroma_norm) + + # Extract mode and Camelot notation + mode = 'major' if 'major' in best_key else 'minor' + key_name = best_key.replace('_major', '').replace('_minor', '') + camelot = self._key_to_camelot(key_name, mode) + + # Detect tonic frequency + tonic_freq = self._detect_tonic_frequency(y, sr, key_name) + + return KeyInfo( + key=key_name, + mode=mode, + camelot=camelot, + confidence=confidence, + chroma_profile=chroma_norm, + tonic_frequency=tonic_freq + ) + + except Exception as e: + logger.error(f"Key analysis failed for {audio_path}: {e}") + raise + + def _match_key_template(self, chroma_norm: np.ndarray) -> Tuple[str, float]: + """Match chroma profile against key templates""" + best_key = None + best_score = 0.0 + + for key_name, template in self.key_templates.items(): + # Calculate correlation with template + correlation = np.corrcoef(chroma_norm, template)[0, 1] + + if not np.isnan(correlation) and correlation > best_score: + best_score = correlation + best_key = key_name + + # Normalize score to 0-1 range + best_score = max(0, (best_score + 1) / 2) + + return best_key or 'C_major', best_score + + def _key_to_camelot(self, key_name: str, mode: str) -> str: + """Convert key name to Camelot wheel notation""" + camelot_major = { + 'C': '8B', 'C#': '3B', 'D': '10B', 'D#': '5B', + 'E': '12B', 'F': '7B', 'F#': '2B', 'G': '9B', + 'G#': '4B', 'A': '11B', 'A#': '6B', 'B': '1B' + } + + camelot_minor = { + 'C': '8A', 'C#': '3A', 'D': '10A', 'D#': '5A', + 'E': '12A', 'F': '7A', 'F#': '2A', 'G': '9A', + 'G#': '4A', 'A': '11A', 'A#': '6A', 'B': '1A' + } + + if mode == 'major': + return camelot_major.get(key_name, '8B') + else: + return camelot_minor.get(key_name, '8A') + + def _detect_tonic_frequency(self, y: np.ndarray, sr: int, key_name: str) -> float: + """Detect the fundamental frequency of the tonic""" + # Map key names to frequencies (A4 = 440 Hz reference) + key_frequencies = { + 'C': 261.63, 'C#': 277.18, 'D': 293.66, 'D#': 311.13, + 'E': 329.63, 'F': 349.23, 'F#': 369.99, 'G': 392.00, + 'G#': 415.30, 'A': 440.00, 'A#': 466.16, 'B': 493.88 + } + + # Use autocorrelation to find fundamental frequency + f0 = librosa.yin(y, fmin=50, fmax=2000, sr=sr) + + # Get the most common f0 estimate + f0_clean = f0[f0 > 0] + if len(f0_clean) > 0: + median_f0 = np.median(f0_clean) + + # Find closest key frequency + key_freq = key_frequencies.get(key_name, 440.0) + + # Return the detected frequency if it's close to expected + if abs(median_f0 - key_freq) / key_freq < 0.1: # Within 10% + return median_f0 + + return key_frequencies.get(key_name, 440.0) + + async def analyze_harmony(self, key1: KeyInfo, key2: KeyInfo) -> HarmonyAnalysis: + """Analyze harmonic relationship between two keys""" + try: + # Determine relationship type + relationship_type = self._determine_relationship(key1, key2) + + # Calculate compatibility score + compatibility_score = self._calculate_compatibility(key1, key2, relationship_type) + + # Generate transition suggestions + transition_suggestions = self._generate_transition_suggestions( + key1, key2, relationship_type + ) + + # Analyze energy change + energy_change = self._analyze_energy_change(key1, key2, relationship_type) + + # Determine emotional impact + emotional_impact = self._determine_emotional_impact( + key1, key2, relationship_type + ) + + return HarmonyAnalysis( + relationship_type=relationship_type, + compatibility_score=compatibility_score, + transition_suggestions=transition_suggestions, + energy_change=energy_change, + emotional_impact=emotional_impact + ) + + except Exception as e: + logger.error(f"Harmony analysis failed: {e}") + raise + + def _determine_relationship(self, key1: KeyInfo, key2: KeyInfo) -> str: + """Determine the harmonic relationship between two keys""" + # Perfect match + if key1.key == key2.key and key1.mode == key2.mode: + return 'perfect_match' + + # Relative major/minor + if self._is_relative_major_minor(key1, key2): + return 'relative_major_minor' + + # Parallel major/minor + if key1.key == key2.key and key1.mode != key2.mode: + return 'parallel_major_minor' + + # Dominant relationship + if self._is_dominant_relationship(key1, key2): + return 'dominant' + + # Subdominant relationship + if self._is_subdominant_relationship(key1, key2): + return 'subdominant' + + # Chromatic mediant + if self._is_chromatic_mediant(key1, key2): + return 'chromatic_mediant' + + # Change mode (same tonic, different mode) + if key1.key == key2.key: + return 'change_mode' + + # Tritone substitution + if self._is_tritone_substitution(key1, key2): + return 'tritone_substitution' + + # Check for moderate or strong dissonance + dissonance_level = self._calculate_dissonance(key1, key2) + if dissonance_level > 0.7: + return 'strong_dissonance' + elif dissonance_level > 0.4: + return 'moderate_dissonance' + + return 'unknown' + + def _is_relative_major_minor(self, key1: KeyInfo, key2: KeyInfo) -> bool: + """Check if keys are relative major/minor""" + # Relative minor is a minor third below major + # Relative major is a minor third above minor + + key_order = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + + idx1 = key_order.index(key1.key) + idx2 = key_order.index(key2.key) + + # Check relative relationships + if key1.mode == 'major' and key2.mode == 'minor': + # Major to relative minor: down 3 semitones + return (idx1 - 3) % 12 == idx2 + elif key1.mode == 'minor' and key2.mode == 'major': + # Minor to relative major: up 3 semitones + return (idx1 + 3) % 12 == idx2 + + return False + + def _is_dominant_relationship(self, key1: KeyInfo, key2: KeyInfo) -> bool: + """Check if keys have dominant relationship""" + key_order = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + + idx1 = key_order.index(key1.key) + idx2 = key_order.index(key2.key) + + # Dominant is up a perfect fifth (7 semitones) + return ((idx1 + 7) % 12 == idx2 or (idx2 + 7) % 12 == idx1) and \ + key1.mode == key2.mode + + def _is_subdominant_relationship(self, key1: KeyInfo, key2: KeyInfo) -> bool: + """Check if keys have subdominant relationship""" + key_order = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + + idx1 = key_order.index(key1.key) + idx2 = key_order.index(key2.key) + + # Subdominant is up a perfect fourth (5 semitones) + return ((idx1 + 5) % 12 == idx2 or (idx2 + 5) % 12 == idx1) and \ + key1.mode == key2.mode + + def _is_chromatic_mediant(self, key1: KeyInfo, key2: KeyInfo) -> bool: + """Check if keys are chromatic mediants""" + key_order = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + + idx1 = key_order.index(key1.key) + idx2 = key_order.index(key2.key) + + # Chromatic mediant relationships (major third or minor third) + interval = (idx2 - idx1) % 12 + return interval in [3, 4, 8, 9] and key1.mode == key2.mode + + def _is_tritone_substitution(self, key1: KeyInfo, key2: KeyInfo) -> bool: + """Check if keys are tritone substitutions""" + key_order = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + + idx1 = key_order.index(key1.key) + idx2 = key_order.index(key2.key) + + # Tritone is 6 semitones + return ((idx1 + 6) % 12 == idx2) and key1.mode == key2.mode + + def _calculate_dissonance(self, key1: KeyInfo, key2: KeyInfo) -> float: + """Calculate dissonance level between two keys""" + # Use chroma correlation to measure dissonance + correlation = np.corrcoef(key1.chroma_profile, key2.chroma_profile)[0, 1] + + if np.isnan(correlation): + return 0.5 + + # Convert correlation to dissonance (inverse relationship) + dissonance = 1.0 - max(0, correlation) + + return dissonance + + def _calculate_compatibility(self, key1: KeyInfo, key2: KeyInfo, relationship_type: str) -> float: + """Calculate compatibility score based on relationship type""" + if relationship_type in self.harmony_rules: + base_score = self.harmony_rules[relationship_type]['score'] + + # Adjust based on confidence levels + confidence_factor = (key1.confidence + key2.confidence) / 2 + + return base_score * confidence_factor + + return 0.5 # Default compatibility for unknown relationships + + def _generate_transition_suggestions(self, key1: KeyInfo, key2: KeyInfo, + relationship_type: str) -> List[str]: + """Generate transition suggestions based on harmonic relationship""" + suggestions = [] + + if relationship_type == 'perfect_match': + suggestions.extend([ + "Direct mix - perfect harmonic compatibility", + "Long crossfade - maintain energy", + "EQ sweep for subtle texture change" + ]) + + elif relationship_type == 'relative_major_minor': + suggestions.extend([ + "Bass note transition to establish new mode", + "Gradual mode shift over 8 bars", + "Highlight the third to emphasize mode change" + ]) + + elif relationship_type == 'parallel_major_minor': + suggestions.extend([ + "Dramatic transition with filter sweep", + "Use the third as pivot point", + "Emphasize the emotional shift with reverb" + ]) + + elif relationship_type == 'dominant': + suggestions.extend([ + "Build tension with dominant chord", + "Resolve quickly for satisfying transition", + "Use riser effect to enhance anticipation" + ]) + + elif relationship_type == 'subdominant': + suggestions.extend([ + "Gentle, resolving transition", + "Use pad sounds to smooth the change", + "Gradual bass movement" + ]) + + elif relationship_type == 'chromatic_mediant': + suggestions.extend([ + "Unexpected but pleasing transition", + "Use delay to create dreamy effect", + "Highlight the chromatic mediant relationship" + ]) + + elif relationship_type == 'change_mode': + suggestions.extend([ + "Emphasize the third or sixth", + "Use filter to change tonal character", + "Create emotional contrast with dynamics" + ]) + + else: + suggestions.extend([ + "Quick cut to minimize dissonance", + "Use effects mask during transition", + "Consider reordering tracks for better flow" + ]) + + return suggestions + + def _analyze_energy_change(self, key1: KeyInfo, key2: KeyInfo, relationship_type: str) -> str: + """Analyze the energy change between keys""" + if relationship_type in self.harmony_rules: + return self.harmony_rules[relationship_type]['energy'] + + # Default energy analysis based on mode change + if key1.mode != key2.mode: + return "mode_shift_energy" + + return "neutral_energy" + + def _determine_emotional_impact(self, key1: KeyInfo, key2: KeyInfo, relationship_type: str) -> str: + """Determine the emotional impact of the transition""" + if relationship_type in self.harmony_rules: + return self.harmony_rules[relationship_type]['emotion'] + + # Default emotional analysis + if key1.mode == 'major' and key2.mode == 'minor': + return "melancholy_shift" + elif key1.mode == 'minor' and key2.mode == 'major': + return "hopeful_rise" + + return "neutral_emotion" + + async def suggest_harmonic_transitions(self, current_key: KeyInfo, + target_keys: List[KeyInfo]) -> List[Tuple[KeyInfo, HarmonyAnalysis]]: + """Suggest the best harmonic transitions from current key to targets""" + transitions = [] + + for target_key in target_keys: + harmony = await self.analyze_harmony(current_key, target_key) + transitions.append((target_key, harmony)) + + # Sort by compatibility score + transitions.sort(key=lambda x: x[1].compatibility_score, reverse=True) + + return transitions + + def create_harmonic_mix_plan(self, keys: List[KeyInfo]) -> Dict: + """Create a harmonic mix plan for a sequence of keys""" + if len(keys) < 2: + return {'error': 'Need at least 2 keys for mix plan'} + + mix_plan = { + 'total_keys': len(keys), + 'overall_compatibility': 0.0, + 'transitions': [], + 'suggestions': [] + } + + total_compatibility = 0.0 + + for i in range(len(keys) - 1): + current_key = keys[i] + next_key = keys[i + 1] + + harmony = self._analyze_harmony_sync(current_key, next_key) + + transition_info = { + 'from_key': f"{current_key.key} {current_key.mode}", + 'to_key': f"{next_key.key} {next_key.mode}", + 'relationship': harmony.relationship_type, + 'compatibility': harmony.compatibility_score, + 'energy_change': harmony.energy_change, + 'emotional_impact': harmony.emotional_impact, + 'suggestions': harmony.transition_suggestions + } + + mix_plan['transitions'].append(transition_info) + total_compatibility += harmony.compatibility_score + + mix_plan['overall_compatibility'] = total_compatibility / (len(keys) - 1) + + # Generate overall suggestions + if mix_plan['overall_compatibility'] >= 0.8: + mix_plan['suggestions'].append("Excellent harmonic flow - mix as planned") + elif mix_plan['overall_compatibility'] >= 0.6: + mix_plan['suggestions'].append("Good harmonic flow - consider minor adjustments") + else: + mix_plan['suggestions'].append("Consider reordering tracks for better harmonic flow") + + return mix_plan + + def _analyze_harmony_sync(self, key1: KeyInfo, key2: KeyInfo) -> HarmonyAnalysis: + """Synchronous harmony analysis (non-async version)""" + relationship_type = self._determine_relationship(key1, key2) + compatibility_score = self._calculate_compatibility(key1, key2, relationship_type) + transition_suggestions = self._generate_transition_suggestions(key1, key2, relationship_type) + energy_change = self._analyze_energy_change(key1, key2, relationship_type) + emotional_impact = self._determine_emotional_impact(key1, key2, relationship_type) + + return HarmonyAnalysis( + relationship_type=relationship_type, + compatibility_score=compatibility_score, + transition_suggestions=transition_suggestions, + energy_change=energy_change, + emotional_impact=emotional_impact + ) diff --git a/services/radio_sync.py b/services/radio_sync.py new file mode 100644 index 00000000..23592305 --- /dev/null +++ b/services/radio_sync.py @@ -0,0 +1,747 @@ +# swingmusic/services/radio_sync.py +import asyncio +import json +import logging +from typing import Dict, List, Optional, Set +from dataclasses import dataclass, asdict +from datetime import datetime, timedelta +import websockets +from websockets.server import WebSocketServerProtocol +import aiohttp +import aiofiles +from pathlib import Path + +logger = logging.getLogger(__name__) + +@dataclass +class RadioStation: + """Radio station information""" + id: str + name: str + type: str # 'artist_radio', 'genre_radio', 'mood_radio', 'custom_radio' + user_id: str + current_track_index: int + tracks: List[Dict] + created_at: datetime + last_played: Optional[datetime] = None + play_count: int = 0 + is_active: bool = False + +@dataclass +class SyncEvent: + """Synchronization event""" + event_type: str # 'play', 'pause', 'skip', 'seek', 'track_change' + station_id: str + user_id: str + timestamp: datetime + data: Dict + device_id: str + +@dataclass +class DeviceInfo: + """Connected device information""" + device_id: str + user_id: str + device_type: str # 'web', 'mobile', 'desktop' + last_seen: datetime + websocket: Optional[WebSocketServerProtocol] = None + current_station: Optional[str] = None + playback_position: float = 0.0 + +class CrossPlatformRadioSync: + """Cross-platform radio synchronization service""" + + def __init__(self): + self.active_stations: Dict[str, RadioStation] = {} + self.connected_devices: Dict[str, DeviceInfo] = {} + self.station_listeners: Dict[str, Set[str]] = {} # station_id -> device_ids + self.user_stations: Dict[str, List[str]] = {} # user_id -> station_ids + + # WebSocket server + self.websocket_server = None + self.server_port = 8765 + + # Configuration + self.sync_timeout = 30 # seconds + self.max_devices_per_user = 5 + self.station_cache_ttl = 3600 # 1 hour + + # Background tasks + self.cleanup_task = None + self.sync_task = None + + async def start_sync_service(self): + """Start the radio synchronization service""" + try: + # Start WebSocket server + self.websocket_server = await websockets.serve( + self._handle_websocket_connection, + "localhost", + self.server_port + ) + + # Start background tasks + self.cleanup_task = asyncio.create_task(self._cleanup_loop()) + self.sync_task = asyncio.create_task(self._sync_loop()) + + logger.info(f"Radio sync service started on port {self.server_port}") + + except Exception as e: + logger.error(f"Failed to start sync service: {e}") + raise + + async def stop_sync_service(self): + """Stop the radio synchronization service""" + try: + # Cancel background tasks + if self.cleanup_task: + self.cleanup_task.cancel() + if self.sync_task: + self.sync_task.cancel() + + # Close WebSocket server + if self.websocket_server: + self.websocket_server.close() + await self.websocket_server.wait_closed() + + # Disconnect all devices + for device in self.connected_devices.values(): + if device.websocket: + await device.websocket.close() + + logger.info("Radio sync service stopped") + + except Exception as e: + logger.error(f"Error stopping sync service: {e}") + + async def create_radio_station(self, user_id: str, station_data: Dict) -> RadioStation: + """Create a new radio station""" + try: + station_id = f"{station_data['type']}_{user_id}_{datetime.now().timestamp()}" + + station = RadioStation( + id=station_id, + name=station_data['name'], + type=station_data['type'], + user_id=user_id, + current_track_index=0, + tracks=station_data['tracks'], + created_at=datetime.utcnow() + ) + + # Store station + self.active_stations[station_id] = station + + # Update user stations + if user_id not in self.user_stations: + self.user_stations[user_id] = [] + self.user_stations[user_id].append(station_id) + + # Initialize listeners set + self.station_listeners[station_id] = set() + + logger.info(f"Created radio station: {station_id}") + return station + + except Exception as e: + logger.error(f"Failed to create radio station: {e}") + raise + + async def get_user_stations(self, user_id: str) -> List[RadioStation]: + """Get all radio stations for a user""" + try: + station_ids = self.user_stations.get(user_id, []) + stations = [] + + for station_id in station_ids: + if station_id in self.active_stations: + stations.append(self.active_stations[station_id]) + + return stations + + except Exception as e: + logger.error(f"Failed to get user stations: {e}") + return [] + + async def join_station(self, user_id: str, device_id: str, station_id: str) -> bool: + """Join a radio station""" + try: + # Check if station exists + if station_id not in self.active_stations: + logger.warning(f"Station not found: {station_id}") + return False + + # Check if device is connected + if device_id not in self.connected_devices: + logger.warning(f"Device not connected: {device_id}") + return False + + # Remove from previous station if any + await self._leave_station(device_id) + + # Add to station listeners + self.station_listeners[station_id].add(device_id) + + # Update device info + device = self.connected_devices[device_id] + device.current_station = station_id + device.playback_position = 0.0 + + # Mark station as active + station = self.active_stations[station_id] + station.is_active = True + + # Send current track to device + await self._send_current_track(device_id, station) + + logger.info(f"Device {device_id} joined station {station_id}") + return True + + except Exception as e: + logger.error(f"Failed to join station: {e}") + return False + + async def leave_station(self, device_id: str) -> bool: + """Leave current radio station""" + try: + return await self._leave_station(device_id) + + except Exception as e: + logger.error(f"Failed to leave station: {e}") + return False + + async def _leave_station(self, device_id: str) -> bool: + """Internal leave station method""" + device = self.connected_devices.get(device_id) + if not device: + return False + + # Remove from station listeners + if device.current_station: + station_id = device.current_station + if station_id in self.station_listeners: + self.station_listeners[station_id].discard(device_id) + + # Check if station should be deactivated + if len(self.station_listeners[station_id]) == 0: + station = self.active_stations.get(station_id) + if station: + station.is_active = False + + # Update device info + device.current_station = None + device.playback_position = 0.0 + + return True + + async def sync_playback_event(self, event: SyncEvent) -> None: + """Synchronize playback event across all devices in station""" + try: + station_id = event.station_id + + # Get station listeners + listeners = self.station_listeners.get(station_id, set()) + + # Broadcast event to all listeners (except sender) + for device_id in listeners: + if device_id != event.device_id: + await self._send_event_to_device(device_id, event) + + # Update station state if needed + if event.event_type == 'track_change': + await self._handle_track_change(station_id, event.data) + elif event.event_type == 'play': + await self._handle_play_event(station_id, event) + elif event.event_type == 'pause': + await self._handle_pause_event(station_id, event) + + except Exception as e: + logger.error(f"Failed to sync playback event: {e}") + + async def _send_event_to_device(self, device_id: str, event: SyncEvent) -> None: + """Send event to specific device""" + try: + device = self.connected_devices.get(device_id) + if not device or not device.websocket: + return + + message = { + 'type': 'sync_event', + 'event': asdict(event) + } + + await device.websocket.send(json.dumps(message)) + + except Exception as e: + logger.error(f"Failed to send event to device {device_id}: {e}") + + async def _send_current_track(self, device_id: str, station: RadioStation) -> None: + """Send current track information to device""" + try: + device = self.connected_devices.get(device_id) + if not device or not device.websocket: + return + + if station.current_track_index < len(station.tracks): + current_track = station.tracks[station.current_track_index] + + message = { + 'type': 'current_track', + 'track': current_track, + 'station': asdict(station), + 'position': device.playback_position + } + + await device.websocket.send(json.dumps(message)) + + except Exception as e: + logger.error(f"Failed to send current track to device {device_id}: {e}") + + async def _handle_websocket_connection(self, websocket: WebSocketServerProtocol, path: str): + """Handle new WebSocket connection""" + try: + # Wait for authentication message + auth_message = await websocket.recv() + auth_data = json.loads(auth_message) + + user_id = auth_data['user_id'] + device_id = auth_data['device_id'] + device_type = auth_data.get('device_type', 'unknown') + + # Check device limit + user_devices = [d for d in self.connected_devices.values() if d.user_id == user_id] + if len(user_devices) >= self.max_devices_per_user: + await websocket.send(json.dumps({ + 'type': 'error', + 'message': 'Device limit exceeded' + })) + await websocket.close() + return + + # Register device + device = DeviceInfo( + device_id=device_id, + user_id=user_id, + device_type=device_type, + last_seen=datetime.utcnow(), + websocket=websocket + ) + + self.connected_devices[device_id] = device + + # Send confirmation + await websocket.send(json.dumps({ + 'type': 'connected', + 'device_id': device_id + })) + + logger.info(f"Device {device_id} connected for user {user_id}") + + # Handle messages + async for message in websocket: + try: + data = json.loads(message) + await self._handle_device_message(device_id, data) + except Exception as e: + logger.error(f"Error handling message from {device_id}: {e}") + + except Exception as e: + logger.error(f"WebSocket connection error: {e}") + finally: + # Cleanup on disconnect + if 'device_id' in locals(): + await self._cleanup_device(device_id) + + async def _handle_device_message(self, device_id: str, data: Dict) -> None: + """Handle message from device""" + try: + device = self.connected_devices.get(device_id) + if not device: + return + + message_type = data.get('type') + + if message_type == 'join_station': + station_id = data['station_id'] + await self.join_station(device.user_id, device_id, station_id) + + elif message_type == 'leave_station': + await self.leave_station(device_id) + + elif message_type == 'playback_event': + event = SyncEvent( + event_type=data['event_type'], + station_id=data['station_id'], + user_id=device.user_id, + timestamp=datetime.utcnow(), + data=data['data'], + device_id=device_id + ) + await self.sync_playback_event(event) + + elif message_type == 'heartbeat': + device.last_seen = datetime.utcnow() + + except Exception as e: + logger.error(f"Error handling device message: {e}") + + async def _handle_track_change(self, station_id: str, data: Dict) -> None: + """Handle track change event""" + try: + station = self.active_stations.get(station_id) + if not station: + return + + new_track_index = data.get('track_index', 0) + + # Validate track index + if 0 <= new_track_index < len(station.tracks): + station.current_track_index = new_track_index + station.play_count += 1 + station.last_played = datetime.utcnow() + + except Exception as e: + logger.error(f"Error handling track change: {e}") + + async def _handle_play_event(self, station_id: str, event: SyncEvent) -> None: + """Handle play event""" + try: + # Update playback position for all devices in station + listeners = self.station_listeners.get(station_id, set()) + for device_id in listeners: + device = self.connected_devices.get(device_id) + if device and device_id != event.device_id: + device.playback_position = event.data.get('position', 0.0) + + except Exception as e: + logger.error(f"Error handling play event: {e}") + + async def _handle_pause_event(self, station_id: str, event: SyncEvent) -> None: + """Handle pause event""" + try: + # Update playback position for all devices in station + listeners = self.station_listeners.get(station_id, set()) + for device_id in listeners: + device = self.connected_devices.get(device_id) + if device and device_id != event.device_id: + device.playback_position = event.data.get('position', 0.0) + + except Exception as e: + logger.error(f"Error handling pause event: {e}") + + async def _cleanup_device(self, device_id: str) -> None: + """Cleanup disconnected device""" + try: + device = self.connected_devices.get(device_id) + if not device: + return + + # Leave current station + await self._leave_station(device_id) + + # Remove from connected devices + del self.connected_devices[device_id] + + logger.info(f"Device {device_id} disconnected") + + except Exception as e: + logger.error(f"Error cleaning up device: {e}") + + async def _cleanup_loop(self) -> None: + """Background cleanup loop""" + while True: + try: + await asyncio.sleep(60) # Run every minute + + current_time = datetime.utcnow() + devices_to_remove = [] + + # Check for inactive devices + for device_id, device in self.connected_devices.items(): + if (current_time - device.last_seen).total_seconds() > self.sync_timeout: + devices_to_remove.append(device_id) + + # Remove inactive devices + for device_id in devices_to_remove: + await self._cleanup_device(device_id) + + # Cleanup old stations + stations_to_remove = [] + for station_id, station in self.active_stations.items(): + if (current_time - station.created_at).total_seconds() > self.station_cache_ttl: + if not station.is_active and len(self.station_listeners.get(station_id, set())) == 0: + stations_to_remove.append(station_id) + + for station_id in stations_to_remove: + await self._cleanup_station(station_id) + + except Exception as e: + logger.error(f"Error in cleanup loop: {e}") + + async def _cleanup_station(self, station_id: str) -> None: + """Cleanup old station""" + try: + station = self.active_stations.get(station_id) + if not station: + return + + # Remove from user stations + user_stations = self.user_stations.get(station.user_id, []) + if station_id in user_stations: + user_stations.remove(station_id) + + # Remove from active stations + del self.active_stations[station_id] + + # Remove listeners + if station_id in self.station_listeners: + del self.station_listeners[station_id] + + logger.info(f"Cleaned up station {station_id}") + + except Exception as e: + logger.error(f"Error cleaning up station: {e}") + + async def _sync_loop(self) -> None: + """Background synchronization loop""" + while True: + try: + await asyncio.sleep(5) # Run every 5 seconds + + # Sync playback positions for active stations + for station_id, station in self.active_stations.items(): + if station.is_active: + await self._sync_station_playback(station_id) + + except Exception as e: + logger.error(f"Error in sync loop: {e}") + + async def _sync_station_playback(self, station_id: str) -> None: + """Synchronize playback for a station""" + try: + listeners = self.station_listeners.get(station_id, set()) + if len(listeners) <= 1: + return # No sync needed for single listener + + # Calculate average playback position + positions = [] + for device_id in listeners: + device = self.connected_devices.get(device_id) + if device: + positions.append(device.playback_position) + + if positions: + avg_position = sum(positions) / len(positions) + + # Sync devices that are significantly out of sync + sync_threshold = 2.0 # 2 seconds + for device_id in listeners: + device = self.connected_devices.get(device_id) + if device and abs(device.playback_position - avg_position) > sync_threshold: + # Send sync command + sync_event = SyncEvent( + event_type='sync_position', + station_id=station_id, + user_id=device.user_id, + timestamp=datetime.utcnow(), + data={'position': avg_position}, + device_id='server' + ) + await self._send_event_to_device(device_id, sync_event) + + except Exception as e: + logger.error(f"Error syncing station playback: {e}") + + async def get_station_status(self, station_id: str) -> Optional[Dict]: + """Get station status information""" + try: + station = self.active_stations.get(station_id) + if not station: + return None + + listeners = self.station_listeners.get(station_id, set()) + listener_info = [] + + for device_id in listeners: + device = self.connected_devices.get(device_id) + if device: + listener_info.append({ + 'device_id': device_id, + 'device_type': device.device_type, + 'playback_position': device.playback_position, + 'last_seen': device.last_seen.isoformat() + }) + + return { + 'station': asdict(station), + 'listeners': listener_info, + 'listener_count': len(listeners), + 'is_active': station.is_active + } + + except Exception as e: + logger.error(f"Error getting station status: {e}") + return None + + async def broadcast_to_user_devices(self, user_id: str, message: Dict) -> None: + """Broadcast message to all devices for a user""" + try: + for device_id, device in self.connected_devices.items(): + if device.user_id == user_id and device.websocket: + await device.websocket.send(json.dumps(message)) + + except Exception as e: + logger.error(f"Error broadcasting to user devices: {e}") + +# Client-side helper for connecting to radio sync +class RadioSyncClient: + """Client-side radio sync connection""" + + def __init__(self, user_id: str, device_id: str, device_type: str = 'web'): + self.user_id = user_id + self.device_id = device_id + self.device_type = device_type + self.websocket = None + self.event_callbacks = {} + self.is_connected = False + + async def connect(self, server_url: str = "ws://localhost:8765") -> bool: + """Connect to radio sync server""" + try: + self.websocket = await websockets.connect(server_url) + + # Send authentication + auth_message = { + 'user_id': self.user_id, + 'device_id': self.device_id, + 'device_type': self.device_type + } + + await self.websocket.send(json.dumps(auth_message)) + + # Wait for confirmation + response = await self.websocket.recv() + response_data = json.loads(response) + + if response_data.get('type') == 'connected': + self.is_connected = True + + # Start message handler + asyncio.create_task(self._message_handler()) + + logger.info(f"Connected to radio sync server as {self.device_id}") + return True + else: + logger.error(f"Connection failed: {response_data}") + return False + + except Exception as e: + logger.error(f"Failed to connect to radio sync server: {e}") + return False + + async def disconnect(self) -> None: + """Disconnect from radio sync server""" + if self.websocket: + await self.websocket.close() + self.websocket = None + self.is_connected = False + logger.info("Disconnected from radio sync server") + + async def join_station(self, station_id: str) -> bool: + """Join a radio station""" + if not self.is_connected: + return False + + try: + message = { + 'type': 'join_station', + 'station_id': station_id + } + + await self.websocket.send(json.dumps(message)) + return True + + except Exception as e: + logger.error(f"Failed to join station: {e}") + return False + + async def leave_station(self) -> bool: + """Leave current radio station""" + if not self.is_connected: + return False + + try: + message = { + 'type': 'leave_station' + } + + await self.websocket.send(json.dumps(message)) + return True + + except Exception as e: + logger.error(f"Failed to leave station: {e}") + return False + + async def send_playback_event(self, station_id: str, event_type: str, data: Dict) -> bool: + """Send playback event""" + if not self.is_connected: + return False + + try: + message = { + 'type': 'playback_event', + 'station_id': station_id, + 'event_type': event_type, + 'data': data + } + + await self.websocket.send(json.dumps(message)) + return True + + except Exception as e: + logger.error(f"Failed to send playback event: {e}") + return False + + def add_event_callback(self, event_type: str, callback: callable) -> None: + """Add callback for specific event types""" + if event_type not in self.event_callbacks: + self.event_callbacks[event_type] = [] + self.event_callbacks[event_type].append(callback) + + async def _message_handler(self) -> None: + """Handle incoming messages""" + try: + async for message in self.websocket: + data = json.loads(message) + message_type = data.get('type') + + if message_type == 'sync_event': + event = data['event'] + event_type = event['event_type'] + + # Call callbacks + if event_type in self.event_callbacks: + for callback in self.event_callbacks[event_type]: + await callback(event) + + elif message_type == 'current_track': + # Handle current track update + if 'current_track' in self.event_callbacks: + for callback in self.event_callbacks['current_track']: + await callback(data) + + elif message_type == 'error': + logger.error(f"Server error: {data['message']}") + + except Exception as e: + logger.error(f"Error in message handler: {e}") + self.is_connected = False + + async def send_heartbeat(self) -> None: + """Send heartbeat to maintain connection""" + if self.is_connected: + try: + await self.websocket.send(json.dumps({'type': 'heartbeat'})) + except Exception as e: + logger.error(f"Failed to send heartbeat: {e}") + self.is_connected = False diff --git a/services/real_time_audio.py b/services/real_time_audio.py new file mode 100644 index 00000000..a22069ec --- /dev/null +++ b/services/real_time_audio.py @@ -0,0 +1,607 @@ +# swingmusic/services/real_time_audio.py +import numpy as np +import librosa +import sounddevice as sd +from typing import Dict, List, Callable, Optional, Tuple +from dataclasses import dataclass +from threading import Thread, Event +import queue +import logging +from scipy import signal +from scipy.io import wavfile + +logger = logging.getLogger(__name__) + +@dataclass +class AudioConfig: + """Audio processing configuration""" + sample_rate: int = 44100 + buffer_size: int = 1024 + channels: int = 2 + dtype: str = 'float32' + block_size: int = 512 + hop_length: int = 256 + +@dataclass +class AudioFeatures: + """Real-time audio features""" + rms_energy: float + zero_crossing_rate: float + spectral_centroid: float + spectral_bandwidth: float + spectral_rolloff: float + mfcc: np.ndarray + chroma: np.ndarray + tempo: float + beat_phase: float + key_strength: np.ndarray + +@dataclass +class AudioEvent: + """Audio event for callbacks""" + timestamp: float + features: AudioFeatures + audio_data: np.ndarray + event_type: str + +class RealTimeAudioProcessor: + """Real-time audio processing engine for DJ features""" + + def __init__(self, config: Optional[AudioConfig] = None): + self.config = config or AudioConfig() + self.is_running = False + self.audio_queue = queue.Queue() + self.feature_queue = queue.Queue() + self.event_callbacks: List[Callable] = [] + + # Audio processing components + self.beat_tracker = BeatTracker(self.config) + self.key_detector = KeyDetector(self.config) + self.effects_processor = EffectsProcessor(self.config) + + # Threading + self.processing_thread = None + self.callback_thread = None + self.stop_event = Event() + + # Audio buffers + self.input_buffer = np.zeros((self.config.buffer_size * 4, self.config.channels)) + self.output_buffer = np.zeros((self.config.buffer_size * 4, self.config.channels)) + self.buffer_index = 0 + + def add_event_callback(self, callback: Callable[[AudioEvent], None]): + """Add callback for audio events""" + self.event_callbacks.append(callback) + + def remove_event_callback(self, callback: Callable[[AudioEvent], None]): + """Remove audio event callback""" + if callback in self.event_callbacks: + self.event_callbacks.remove(callback) + + def start_processing(self): + """Start real-time audio processing""" + if self.is_running: + logger.warning("Audio processing already running") + return + + self.is_running = True + self.stop_event.clear() + + # Start processing threads + self.processing_thread = Thread(target=self._processing_loop, daemon=True) + self.callback_thread = Thread(target=self._callback_loop, daemon=True) + + self.processing_thread.start() + self.callback_thread.start() + + logger.info("Real-time audio processing started") + + def stop_processing(self): + """Stop real-time audio processing""" + if not self.is_running: + return + + self.is_running = False + self.stop_event.set() + + # Wait for threads to finish + if self.processing_thread: + self.processing_thread.join(timeout=1.0) + if self.callback_thread: + self.callback_thread.join(timeout=1.0) + + logger.info("Real-time audio processing stopped") + + def process_audio_chunk(self, audio_data: np.ndarray): + """Process incoming audio chunk""" + if not self.is_running: + return + + try: + # Add to processing queue + self.audio_queue.put(audio_data, block=False) + except queue.Full: + logger.warning("Audio queue full, dropping chunk") + + def _processing_loop(self): + """Main audio processing loop""" + while self.is_running and not self.stop_event.is_set(): + try: + # Get audio data with timeout + audio_data = self.audio_queue.get(timeout=0.1) + + # Process audio + features = self._extract_features(audio_data) + + # Create audio event + event = AudioEvent( + timestamp=self._get_timestamp(), + features=features, + audio_data=audio_data, + event_type='audio_features' + ) + + # Add to feature queue + self.feature_queue.put(event, block=False) + + except queue.Empty: + continue + except Exception as e: + logger.error(f"Audio processing error: {e}") + + def _callback_loop(self): + """Callback processing loop""" + while self.is_running and not self.stop_event.is_set(): + try: + # Get event with timeout + event = self.feature_queue.get(timeout=0.1) + + # Call all callbacks + for callback in self.event_callbacks: + try: + callback(event) + except Exception as e: + logger.error(f"Callback error: {e}") + + except queue.Empty: + continue + except Exception as e: + logger.error(f"Callback loop error: {e}") + + def _extract_features(self, audio_data: np.ndarray) -> AudioFeatures: + """Extract real-time audio features""" + try: + # Convert to mono if needed + if audio_data.shape[1] > 1: + audio_mono = np.mean(audio_data, axis=1) + else: + audio_mono = audio_data.flatten() + + # Basic features + rms_energy = np.sqrt(np.mean(audio_mono ** 2)) + zero_crossing_rate = librosa.feature.zero_crossing_rate(audio_mono)[0] + + # Spectral features + spectral_centroids = librosa.feature.spectral_centroid( + y=audio_mono, sr=self.config.sample_rate + )[0] + spectral_bandwidth = librosa.feature.spectral_bandwidth( + y=audio_mono, sr=self.config.sample_rate + )[0] + spectral_rolloff = librosa.feature.spectral_rolloff( + y=audio_mono, sr=self.config.sample_rate + )[0] + + # MFCC + mfcc = librosa.feature.mfcc( + y=audio_mono, sr=self.config.sample_rate, n_mfcc=13 + ) + + # Chroma + chroma = librosa.feature.chroma_stft( + y=audio_mono, sr=self.config.sample_rate + ) + + # Tempo and beat tracking + tempo, beats = librosa.beat.beat_track( + y=audio_mono, sr=self.config.sample_rate, hop_length=self.config.hop_length + ) + beat_phase = self._calculate_beat_phase(beats, len(audio_mono)) + + # Key strength + key_strength = np.mean(chroma, axis=1) + + return AudioFeatures( + rms_energy=float(rms_energy), + zero_crossing_rate=float(np.mean(zero_crossing_rate)), + spectral_centroid=float(np.mean(spectral_centroids)), + spectral_bandwidth=float(np.mean(spectral_bandwidth)), + spectral_rolloff=float(np.mean(spectral_rolloff)), + mfcc=mfcc, + chroma=chroma, + tempo=float(tempo), + beat_phase=float(beat_phase), + key_strength=key_strength + ) + + except Exception as e: + logger.error(f"Feature extraction error: {e}") + # Return default features + return AudioFeatures( + rms_energy=0.0, zero_crossing_rate=0.0, spectral_centroid=0.0, + spectral_bandwidth=0.0, spectral_rolloff=0.0, mfcc=np.zeros((13, 1)), + chroma=np.zeros((12, 1)), tempo=120.0, beat_phase=0.0, + key_strength=np.zeros(12) + ) + + def _calculate_beat_phase(self, beats: np.ndarray, audio_length: int) -> float: + """Calculate current beat phase""" + if len(beats) == 0: + return 0.0 + + # Find the most recent beat + current_frame = audio_length // self.config.hop_length + recent_beats = beats[beats < current_frame] + + if len(recent_beats) == 0: + return 0.0 + + last_beat = recent_beats[-1] + beat_duration = 60.0 / 120.0 # Assume 120 BPM if no tempo detected + + # Calculate phase within beat + frames_since_beat = current_frame - last_beat + time_since_beat = frames_since_beat * self.config.hop_length / self.config.sample_rate + + phase = (time_since_beat % beat_duration) / beat_duration + return phase + + def _get_timestamp(self) -> float: + """Get current timestamp""" + import time + return time.time() + + def apply_real_time_effect(self, audio_data: np.ndarray, effect_type: str, + params: Dict) -> np.ndarray: + """Apply real-time audio effect""" + return self.effects_processor.process(audio_data, effect_type, params) + +class BeatTracker: + """Real-time beat tracking""" + + def __init__(self, config: AudioConfig): + self.config = config + self.tempo_history = [] + self.max_history = 10 + + def track_beat(self, audio_data: np.ndarray) -> Tuple[float, np.ndarray]: + """Track beats in real-time audio""" + try: + # Convert to mono + if audio_data.shape[1] > 1: + audio_mono = np.mean(audio_data, axis=1) + else: + audio_mono = audio_data.flatten() + + # Track tempo and beats + tempo, beats = librosa.beat.beat_track( + y=audio_mono, sr=self.config.sample_rate, hop_length=self.config.hop_length + ) + + # Update tempo history + self.tempo_history.append(tempo) + if len(self.tempo_history) > self.max_history: + self.tempo_history.pop(0) + + # Use median tempo for stability + stable_tempo = np.median(self.tempo_history) if self.tempo_history else tempo + + return float(stable_tempo), beats + + except Exception as e: + logger.error(f"Beat tracking error: {e}") + return 120.0, np.array([]) + +class KeyDetector: + """Real-time key detection""" + + def __init__(self, config: AudioConfig): + self.config = config + self.key_history = [] + self.max_history = 5 + + def detect_key(self, audio_data: np.ndarray) -> Tuple[str, float]: + """Detect key in real-time audio""" + try: + # Convert to mono + if audio_data.shape[1] > 1: + audio_mono = np.mean(audio_data, axis=1) + else: + audio_mono = audio_data.flatten() + + # Extract chroma + chroma = librosa.feature.chroma_stft( + y=audio_mono, sr=self.config.sample_rate + ) + + # Average chroma + chroma_mean = np.mean(chroma, axis=1) + + # Simple key detection (would need more sophisticated implementation) + key_idx = np.argmax(chroma_mean) + key_names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + detected_key = key_names[key_idx] + + # Calculate confidence + confidence = np.max(chroma_mean) / np.sum(chroma_mean) if np.sum(chroma_mean) > 0 else 0.0 + + # Update history + self.key_history.append((detected_key, confidence)) + if len(self.key_history) > self.max_history: + self.key_history.pop(0) + + # Use most frequent key + if self.key_history: + keys = [k for k, _ in self.key_history] + most_common_key = max(set(keys), key=keys.count) + avg_confidence = np.mean([c for _, c in self.key_history if k == most_common_key]) + return most_common_key, avg_confidence + + return detected_key, confidence + + except Exception as e: + logger.error(f"Key detection error: {e}") + return 'C', 0.0 + +class EffectsProcessor: + """Real-time audio effects processor""" + + def __init__(self, config: AudioConfig): + self.config = config + + def process(self, audio_data: np.ndarray, effect_type: str, params: Dict) -> np.ndarray: + """Process audio with specified effect""" + try: + if effect_type == 'reverb': + return self._apply_reverb(audio_data, params) + elif effect_type == 'delay': + return self._apply_delay(audio_data, params) + elif effect_type == 'filter': + return self._apply_filter(audio_data, params) + elif effect_type == 'eq': + return self._apply_eq(audio_data, params) + elif effect_type == 'compressor': + return self._apply_compressor(audio_data, params) + elif effect_type == 'distortion': + return self._apply_distortion(audio_data, params) + else: + return audio_data + + except Exception as e: + logger.error(f"Effect processing error: {e}") + return audio_data + + def _apply_reverb(self, audio_data: np.ndarray, params: Dict) -> np.ndarray: + """Apply reverb effect""" + delay_time = params.get('delay_time', 0.03) + decay = params.get('decay', 0.5) + mix = params.get('mix', 0.3) + + # Simple reverb using delay and feedback + delay_samples = int(delay_time * self.config.sample_rate) + + if delay_samples >= len(audio_data): + return audio_data + + # Create delayed version + delayed = np.zeros_like(audio_data) + delayed[delay_samples:] = audio_data[:-delay_samples] * decay + + # Mix with original + return audio_data * (1 - mix) + delayed * mix + + def _apply_delay(self, audio_data: np.ndarray, params: Dict) -> np.ndarray: + """Apply delay effect""" + delay_time = params.get('delay_time', 0.25) + feedback = params.get('feedback', 0.4) + mix = params.get('mix', 0.3) + + delay_samples = int(delay_time * self.config.sample_rate) + + if delay_samples >= len(audio_data): + return audio_data + + # Create delayed signal with feedback + delayed = np.zeros_like(audio_data) + delayed[delay_samples:] = audio_data[:-delay_samples] + + # Add feedback + for i in range(delay_samples, len(audio_data)): + delayed[i] += delayed[i - delay_samples] * feedback + + # Mix with original + return audio_data * (1 - mix) + delayed * mix + + def _apply_filter(self, audio_data: np.ndarray, params: Dict) -> np.ndarray: + """Apply filter effect""" + filter_type = params.get('type', 'lowpass') + cutoff = params.get('cutoff', 1000) + order = params.get('order', 4) + + nyquist = self.config.sample_rate / 2 + normalized_cutoff = cutoff / nyquist + + if filter_type == 'lowpass': + b, a = signal.butter(order, normalized_cutoff, btype='low') + elif filter_type == 'highpass': + b, a = signal.butter(order, normalized_cutoff, btype='high') + elif filter_type == 'bandpass': + low = params.get('low', 500) / nyquist + high = params.get('high', 2000) / nyquist + b, a = signal.butter(order, [low, high], btype='band') + else: + return audio_data + + # Apply filter to each channel + filtered = np.zeros_like(audio_data) + for ch in range(audio_data.shape[1]): + filtered[:, ch] = signal.filtfilt(b, a, audio_data[:, ch]) + + return filtered + + def _apply_eq(self, audio_data: np.ndarray, params: Dict) -> np.ndarray: + """Apply EQ effect""" + # Simple 3-band EQ + low_gain = params.get('low_gain', 0) # dB + mid_gain = params.get('mid_gain', 0) # dB + high_gain = params.get('high_gain', 0) # dB + + # Convert dB to linear + low_gain_lin = 10 ** (low_gain / 20) + mid_gain_lin = 10 ** (mid_gain / 20) + high_gain_lin = 10 ** (high_gain / 20) + + # Apply simple EQ (would need more sophisticated implementation) + result = audio_data.copy() + + # Apply gains (simplified - real EQ would use filters) + result *= (low_gain_lin + mid_gain_lin + high_gain_lin) / 3 + + return result + + def _apply_compressor(self, audio_data: np.ndarray, params: Dict) -> np.ndarray: + """Apply compressor effect""" + threshold = params.get('threshold', 0.7) + ratio = params.get('ratio', 4) + attack = params.get('attack', 0.003) + release = params.get('release', 0.1) + + # Simple compressor implementation + result = audio_data.copy() + + for ch in range(audio_data.shape[1]): + channel_data = audio_data[:, ch] + + # Calculate envelope + envelope = np.abs(channel_data) + + # Apply gain reduction + gain_reduction = np.where( + envelope > threshold, + 1 - (envelope - threshold) * (1 - 1/ratio) / envelope, + 1.0 + ) + + # Smooth gain reduction + gain_reduction = self._smooth_gain(gain_reduction, attack, release) + + # Apply gain reduction + result[:, ch] *= gain_reduction + + return result + + def _apply_distortion(self, audio_data: np.ndarray, params: Dict) -> np.ndarray: + """Apply distortion effect""" + drive = params.get('drive', 5) + mix = params.get('mix', 0.5) + + # Apply distortion + distorted = np.tanh(audio_data * drive) + + # Mix with original + return audio_data * (1 - mix) + distorted * mix + + def _smooth_gain(self, gain_reduction: np.ndarray, attack: float, release: float) -> np.ndarray: + """Smooth gain reduction with attack and release""" + # Simplified gain smoothing + smoothed = np.zeros_like(gain_reduction) + smoothed[0] = gain_reduction[0] + + attack_coeff = np.exp(-1.0 / (attack * self.config.sample_rate)) + release_coeff = np.exp(-1.0 / (release * self.config.sample_rate)) + + for i in range(1, len(gain_reduction)): + if gain_reduction[i] < smoothed[i-1]: + # Attack + smoothed[i] = attack_coeff * smoothed[i-1] + (1 - attack_coeff) * gain_reduction[i] + else: + # Release + smoothed[i] = release_coeff * smoothed[i-1] + (1 - release_coeff) * gain_reduction[i] + + return smoothed + +class AudioStreamManager: + """Manage audio input/output streams""" + + def __init__(self, processor: RealTimeAudioProcessor): + self.processor = processor + self.input_stream = None + self.output_stream = None + + def start_input_stream(self, device_id: Optional[int] = None): + """Start audio input stream""" + try: + self.input_stream = sd.InputStream( + samplerate=self.processor.config.sample_rate, + channels=self.processor.config.channels, + dtype=self.processor.config.dtype, + blocksize=self.processor.config.block_size, + device=device_id, + callback=self._input_callback + ) + self.input_stream.start() + logger.info("Audio input stream started") + + except Exception as e: + logger.error(f"Failed to start input stream: {e}") + raise + + def stop_input_stream(self): + """Stop audio input stream""" + if self.input_stream: + self.input_stream.stop() + self.input_stream.close() + self.input_stream = None + logger.info("Audio input stream stopped") + + def start_output_stream(self, device_id: Optional[int] = None): + """Start audio output stream""" + try: + self.output_stream = sd.OutputStream( + samplerate=self.processor.config.sample_rate, + channels=self.processor.config.channels, + dtype=self.processor.config.dtype, + blocksize=self.processor.config.block_size, + device=device_id, + callback=self._output_callback + ) + self.output_stream.start() + logger.info("Audio output stream started") + + except Exception as e: + logger.error(f"Failed to start output stream: {e}") + raise + + def stop_output_stream(self): + """Stop audio output stream""" + if self.output_stream: + self.output_stream.stop() + self.output_stream.close() + self.output_stream = None + logger.info("Audio output stream stopped") + + def _input_callback(self, indata, frames, time, status): + """Audio input callback""" + if status: + logger.warning(f"Input stream status: {status}") + + # Process incoming audio + self.processor.process_audio_chunk(indata) + + def _output_callback(self, outdata, frames, time, status): + """Audio output callback""" + if status: + logger.warning(f"Output stream status: {status}") + + # Generate output (would need audio source) + outdata.fill(0) # Silence for now diff --git a/src/swingmusic/api/__init__.py b/src/swingmusic/api/__init__.py index 7b8f99d5..e334743d 100644 --- a/src/swingmusic/api/__init__.py +++ b/src/swingmusic/api/__init__.py @@ -21,6 +21,14 @@ from swingmusic.api import ( auth, stream, backup_and_restore, + spotify, + spotify_settings, + enhanced_search, + universal_downloader, + music_catalog, + update_tracking, + audio_quality, + upload, ) from swingmusic.api.plugins import lyrics as lyrics_plugin @@ -28,7 +36,7 @@ from swingmusic.api.plugins import mixes as mixes_plugin __all__ = [ "album", "artist", "collections", "colors", "favorites", "folder", "imgserver", "playlist", "search", "settings", - "lyrics", "plugins", "scrobble", "home", "getall", "auth", "stream", "backup_and_restore", + "lyrics", "plugins", "scrobble", "home", "getall", "auth", "stream", "backup_and_restore", "spotify", "spotify_settings", "enhanced_search", "universal_downloader", "music_catalog", "update_tracking", "audio_quality", "upload", "lyrics_plugin", "mixes_plugin" diff --git a/src/swingmusic/api/advanced_ux.py b/src/swingmusic/api/advanced_ux.py new file mode 100644 index 00000000..5a407620 --- /dev/null +++ b/src/swingmusic/api/advanced_ux.py @@ -0,0 +1,624 @@ +""" +Advanced UX API Endpoints + +This module provides REST API endpoints for enhanced user experience features, +including intelligent search suggestions, recommendations, and personalization. +""" + +import logging +from datetime import datetime +from typing import Dict, List, Optional, Any +from flask import Blueprint, request, jsonify +from flask_login import login_required, current_user + +from swingmusic.db import db +from swingmusic.services.advanced_ux_service import advanced_ux_service, SuggestionType, SearchContext +from swingmusic.utils.request import APIError, success_response, error_response +from swingmusic.utils.validators import validate_search_query, validate_context + +logger = logging.getLogger(__name__) + +advanced_ux_bp = Blueprint('advanced_ux', __name__, url_prefix='/api/ux') + + +def get_current_user_id() -> int: + """Get current user ID from Flask-Login""" + return current_user.id if current_user.is_authenticated else None + + +@advanced_ux_bp.route('/search/suggestions', methods=['GET']) +@login_required +async def get_search_suggestions(): + """ + Get intelligent search suggestions + + Query Parameters: + - q: Search query + - context: Search context (general, discovery, download, playlist, offline, social) + - limit: Maximum suggestions to return (default: 10) + """ + try: + user_id = get_current_user_id() + query = request.args.get('q', '').strip() + context_str = request.args.get('context', 'general') + limit = min(request.args.get('limit', 10, type=int), 50) + + # Validate inputs + validate_search_query(query) + context = validate_context(context_str) + + # Get suggestions + suggestions = await advanced_ux_service.get_search_suggestions(user_id, query, context, limit) + + # Format response + formatted_suggestions = [] + for suggestion in suggestions: + formatted_suggestion = { + 'id': suggestion.id, + 'type': suggestion.type.value, + 'title': suggestion.title, + 'subtitle': suggestion.subtitle, + 'image_url': suggestion.image_url, + 'url': suggestion.url, + 'metadata': suggestion.metadata, + 'relevance_score': suggestion.relevance_score, + 'context': suggestion.context.value + } + formatted_suggestions.append(formatted_suggestion) + + return success_response({ + 'suggestions': formatted_suggestions, + 'query': query, + 'context': context.value, + 'total_count': len(formatted_suggestions) + }) + + except Exception as e: + logger.error(f"Error getting search suggestions: {e}") + return error_response("Internal server error", 500) + + +@advanced_ux_bp.route('/discovery/recommendations', methods=['GET']) +@login_required +async def get_discovery_recommendations(): + """ + Get personalized discovery recommendations + + Query Parameters: + - type: Recommendation type (tracks, artists, albums, mixed) + - limit: Maximum recommendations to return (default: 20) + """ + try: + user_id = get_current_user_id() + recommendation_type = request.args.get('type', 'mixed') + limit = min(request.args.get('limit', 20, type=int), 100) + + # Validate recommendation type + valid_types = ['tracks', 'artists', 'albums', 'mixed'] + if recommendation_type not in valid_types: + return error_response(f"Invalid type. Must be one of: {valid_types}", 400) + + # Get recommendations + recommendations = await advanced_ux_service.get_discovery_recommendations(user_id, recommendation_type, limit) + + # Format response + formatted_recommendations = [] + for recommendation in recommendations: + formatted_recommendation = { + 'id': recommendation.id, + 'type': recommendation.type.value, + 'title': recommendation.title, + 'subtitle': recommendation.subtitle, + 'image_url': recommendation.image_url, + 'url': recommendation.url, + 'metadata': recommendation.metadata, + 'relevance_score': recommendation.relevance_score + } + formatted_recommendations.append(formatted_recommendation) + + return success_response({ + 'recommendations': formatted_recommendations, + 'type': recommendation_type, + 'total_count': len(formatted_recommendations) + }) + + except Exception as e: + logger.error(f"Error getting discovery recommendations: {e}") + return error_response("Internal server error", 500) + + +@advanced_ux_bp.route('/contextual/suggestions', methods=['GET']) +@login_required +async def get_contextual_suggestions(): + """ + Get contextual suggestions based on current track + + Query Parameters: + - track_id: Currently playing track ID + - context_type: Context type (similar, same_artist, same_genre, popular) + """ + try: + user_id = get_current_user_id() + track_id = request.args.get('track_id') + context_type = request.args.get('context_type', 'similar') + + if not track_id: + return error_response("track_id is required", 400) + + # Validate context type + valid_contexts = ['similar', 'same_artist', 'same_genre', 'popular'] + if context_type not in valid_contexts: + return error_response(f"Invalid context_type. Must be one of: {valid_contexts}", 400) + + # Get contextual suggestions + suggestions = await advanced_ux_service.get_contextual_suggestions(user_id, track_id, context_type) + + # Format response + formatted_suggestions = [] + for suggestion in suggestions: + formatted_suggestion = { + 'id': suggestion.id, + 'type': suggestion.type.value, + 'title': suggestion.title, + 'subtitle': suggestion.subtitle, + 'image_url': suggestion.image_url, + 'url': suggestion.url, + 'metadata': suggestion.metadata, + 'relevance_score': suggestion.relevance_score + } + formatted_suggestions.append(formatted_suggestion) + + return success_response({ + 'suggestions': formatted_suggestions, + 'track_id': track_id, + 'context_type': context_type, + 'total_count': len(formatted_suggestions) + }) + + except Exception as e: + logger.error(f"Error getting contextual suggestions: {e}") + return error_response("Internal server error", 500) + + +@advanced_ux_bp.route('/download/suggestions', methods=['GET']) +@login_required +async def get_download_suggestions(): + """ + Get download-specific suggestions with universal downloader integration + + Query Parameters: + - q: Search query (optional) + - limit: Maximum suggestions to return (default: 15) + """ + try: + user_id = get_current_user_id() + query = request.args.get('q', '').strip() + limit = min(request.args.get('limit', 15, type=int), 50) + + # Get download suggestions + suggestions = await advanced_ux_service.get_download_suggestions(user_id, query, limit) + + # Format response + formatted_suggestions = [] + for suggestion in suggestions: + formatted_suggestion = { + 'id': suggestion.id, + 'type': suggestion.type.value, + 'title': suggestion.title, + 'subtitle': suggestion.subtitle, + 'image_url': suggestion.image_url, + 'url': suggestion.url, + 'metadata': suggestion.metadata, + 'relevance_score': suggestion.relevance_score + } + formatted_suggestions.append(formatted_suggestion) + + return success_response({ + 'suggestions': formatted_suggestions, + 'query': query, + 'total_count': len(formatted_suggestions) + }) + + except Exception as e: + logger.error(f"Error getting download suggestions: {e}") + return error_response("Internal server error", 500) + + +@advanced_ux_bp.route('/search/filters', methods=['GET']) +@login_required +async def get_enhanced_search_filters(): + """ + Get enhanced search filters with user personalization + """ + try: + user_id = get_current_user_id() + + # Get enhanced filters + filters = await advanced_ux_service.get_enhanced_search_filters(user_id) + + # Format response + formatted_filters = [] + for filter_item in filters: + formatted_filter = { + 'filter_id': filter_item.filter_id, + 'name': filter_item.name, + 'type': filter_item.type, + 'options': filter_item.options, + 'is_active': filter_item.is_active, + 'is_multi_select': filter_item.is_multi_select + } + formatted_filters.append(formatted_filter) + + return success_response({ + 'filters': formatted_filters, + 'total_count': len(formatted_filters) + }) + + except Exception as e: + logger.error(f"Error getting enhanced search filters: {e}") + return error_response("Internal server error", 500) + + +@advanced_ux_bp.route('/behavior/track', methods=['POST']) +@login_required +async def track_user_behavior(): + """ + Track user behavior for personalization + + Request Body: + { + "type": "search|play|download|like", + "data": { + "query": "search query", + "track_id": "track_id", + "artist": "artist_name", + "timestamp": "ISO timestamp", + "context": "context information" + } + } + """ + try: + user_id = get_current_user_id() + data = request.get_json() + + if not data: + return error_response("Request body is required", 400) + + interaction_type = data.get('type') + interaction_data = data.get('data', {}) + + # Validate interaction type + valid_types = ['search', 'play', 'download', 'like'] + if interaction_type not in valid_types: + return error_response(f"Invalid type. Must be one of: {valid_types}", 400) + + # Add user ID and timestamp to interaction data + interaction_data['user_id'] = user_id + if 'timestamp' not in interaction_data: + interaction_data['timestamp'] = datetime.utcnow().isoformat() + + # Update user behavior + await advanced_ux_service.update_user_behavior(user_id, interaction_data) + + return success_response({ + 'message': 'User behavior tracked successfully' + }) + + except Exception as e: + logger.error(f"Error tracking user behavior: {e}") + return error_response("Internal server error", 500) + + +@advanced_ux_bp.route('/behavior/profile', methods=['GET']) +@login_required +async def get_user_behavior_profile(): + """ + Get user behavior profile for personalization insights + """ + try: + user_id = get_current_user_id() + + # Get user behavior + behavior = await advanced_ux_service._get_user_behavior(user_id) + + # Format response + profile = { + 'user_id': behavior.user_id, + 'favorite_genres': behavior.favorite_genres, + 'favorite_artists': behavior.favorite_artists, + 'listening_patterns': behavior.listening_patterns, + 'download_preferences': behavior.download_preferences, + 'interaction_patterns': behavior.interaction_patterns, + 'last_updated': behavior.last_updated.isoformat(), + 'search_history_count': len(behavior.search_history), + 'recent_searches': behavior.search_history[-5:] if behavior.search_history else [] + } + + return success_response({ + 'profile': profile + }) + + except Exception as e: + logger.error(f"Error getting user behavior profile: {e}") + return error_response("Internal server error", 500) + + +@advanced_ux_bp.route('/trending/content', methods=['GET']) +@login_required +async def get_trending_content(): + """ + Get trending content based on user preferences and global trends + + Query Parameters: + - type: Content type (tracks, artists, albums, mixed) + - limit: Maximum items to return (default: 20) + - timeframe: Timeframe for trends (day, week, month, all) + """ + try: + user_id = get_current_user_id() + content_type = request.args.get('type', 'mixed') + limit = min(request.args.get('limit', 20, type=int), 100) + timeframe = request.args.get('timeframe', 'week') + + # Validate inputs + valid_types = ['tracks', 'artists', 'albums', 'mixed'] + if content_type not in valid_types: + return error_response(f"Invalid type. Must be one of: {valid_types}", 400) + + valid_timeframes = ['day', 'week', 'month', 'all'] + if timeframe not in valid_timeframes: + return error_response(f"Invalid timeframe. Must be one of: {valid_timeframes}", 400) + + # Get trending content (this would integrate with analytics) + # For now, return discovery recommendations as trending + trending = await advanced_ux_service.get_discovery_recommendations(user_id, content_type, limit) + + # Format response + formatted_trending = [] + for item in trending: + formatted_item = { + 'id': item.id, + 'type': item.type.value, + 'title': item.title, + 'subtitle': item.subtitle, + 'image_url': item.image_url, + 'url': item.url, + 'metadata': item.metadata, + 'relevance_score': item.relevance_score, + 'trend_score': item.relevance_score # Would calculate actual trend score + } + formatted_trending.append(formatted_item) + + return success_response({ + 'trending': formatted_trending, + 'type': content_type, + 'timeframe': timeframe, + 'total_count': len(formatted_trending) + }) + + except Exception as e: + logger.error(f"Error getting trending content: {e}") + return error_response("Internal server error", 500) + + +@advanced_ux_bp.route('/search/advanced', methods=['POST']) +@login_required +async def advanced_search(): + """ + Perform advanced search with filters and personalization + + Request Body: + { + "query": "search query", + "filters": { + "genre": ["rock", "pop"], + "mood": "energetic", + "year": ["2020", "2021"], + "quality": "high", + "duration": "medium" + }, + "sort_by": "relevance|popularity|date", + "sort_order": "asc|desc", + "limit": 20, + "offset": 0 + } + """ + try: + user_id = get_current_user_id() + data = request.get_json() + + if not data: + return error_response("Request body is required", 400) + + query = data.get('query', '').strip() + filters = data.get('filters', {}) + sort_by = data.get('sort_by', 'relevance') + sort_order = data.get('sort_order', 'desc') + limit = min(data.get('limit', 20, type=int), 100) + offset = max(data.get('offset', 0, type=int), 0) + + # Validate inputs + validate_search_query(query) + + valid_sort_by = ['relevance', 'popularity', 'date', 'title', 'artist'] + if sort_by not in valid_sort_by: + return error_response(f"Invalid sort_by. Must be one of: {valid_sort_by}", 400) + + valid_sort_order = ['asc', 'desc'] + if sort_order not in valid_sort_order: + return error_response(f"Invalid sort_order. Must be one of: {valid_sort_order}", 400) + + # Perform advanced search + # This would implement complex search logic with filters + # For now, use basic search suggestions as placeholder + context = SearchContext.GENERAL + if filters.get('quality') == 'lossless' or 'download' in query.lower(): + context = SearchContext.DOWNLOAD + + suggestions = await advanced_ux_service.get_search_suggestions(user_id, query, context, limit + offset) + + # Apply filters (simplified) + filtered_suggestions = [] + for suggestion in suggestions: + include = True + + # Genre filter + if 'genre' in filters and filters['genre']: + if not any(genre.lower() in (suggestion.subtitle or '').lower() for genre in filters['genre']): + include = False + + # Quality filter + if 'quality' in filters and filters['quality']: + if filters['quality'] not in (suggestion.subtitle or '').lower(): + include = False + + if include: + filtered_suggestions.append(suggestion) + + # Sort results + if sort_by == 'relevance': + filtered_suggestions.sort(key=lambda x: x.relevance_score, reverse=(sort_order == 'desc')) + elif sort_by == 'title': + filtered_suggestions.sort(key=lambda x: x.title.lower(), reverse=(sort_order == 'desc')) + elif sort_by == 'artist': + filtered_suggestions.sort(key=lambda x: (x.subtitle or '').lower(), reverse=(sort_order == 'desc')) + + # Apply pagination + paginated_suggestions = filtered_suggestions[offset:offset + limit] + + # Format response + formatted_results = [] + for suggestion in paginated_suggestions: + formatted_result = { + 'id': suggestion.id, + 'type': suggestion.type.value, + 'title': suggestion.title, + 'subtitle': suggestion.subtitle, + 'image_url': suggestion.image_url, + 'url': suggestion.url, + 'metadata': suggestion.metadata, + 'relevance_score': suggestion.relevance_score + } + formatted_results.append(formatted_result) + + return success_response({ + 'results': formatted_results, + 'query': query, + 'filters': filters, + 'sort_by': sort_by, + 'sort_order': sort_order, + 'total_count': len(filtered_suggestions), + 'limit': limit, + 'offset': offset + }) + + except Exception as e: + logger.error(f"Error performing advanced search: {e}") + return error_response("Internal server error", 500) + + +@advanced_ux_bp.route('/suggestions/quick', methods=['GET']) +@login_required +async def get_quick_suggestions(): + """ + Get quick suggestions for UI components (autocomplete, etc.) + + Query Parameters: + - type: Suggestion type (search, discovery, download) + - limit: Maximum suggestions (default: 5) + """ + try: + user_id = get_current_user_id() + suggestion_type = request.args.get('type', 'search') + limit = min(request.args.get('limit', 5, type=int), 20) + + # Validate suggestion type + valid_types = ['search', 'discovery', 'download'] + if suggestion_type not in valid_types: + return error_response(f"Invalid type. Must be one of: {valid_types}", 400) + + suggestions = [] + + if suggestion_type == 'search': + # Get default search suggestions + suggestions = await advanced_ux_service._get_default_suggestions(user_id, SearchContext.GENERAL, limit) + elif suggestion_type == 'discovery': + # Get discovery recommendations + suggestions = await advanced_ux_service.get_discovery_recommendations(user_id, 'mixed', limit) + elif suggestion_type == 'download': + # Get download suggestions + suggestions = await advanced_ux_service.get_download_suggestions(user_id, '', limit) + + # Format response for quick UI + formatted_suggestions = [] + for suggestion in suggestions: + formatted_suggestion = { + 'id': suggestion.id, + 'type': suggestion.type.value, + 'title': suggestion.title, + 'subtitle': suggestion.subtitle, + 'image_url': suggestion.image_url, + 'url': suggestion.url + } + formatted_suggestions.append(formatted_suggestion) + + return success_response({ + 'suggestions': formatted_suggestions, + 'type': suggestion_type, + 'total_count': len(formatted_suggestions) + }) + + except Exception as e: + logger.error(f"Error getting quick suggestions: {e}") + return error_response("Internal server error", 500) + + +@advanced_ux_bp.route('/personalization/preferences', methods=['GET', 'PUT']) +@login_required +async def personalization_preferences(): + """ + Get or update personalization preferences + + GET: Returns current preferences + PUT: Updates preferences + """ + try: + user_id = get_current_user_id() + + if request.method == 'GET': + # Get user behavior profile + behavior = await advanced_ux_service._get_user_behavior(user_id) + + preferences = { + 'favorite_genres': behavior.favorite_genres, + 'favorite_artists': behavior.favorite_artists, + 'download_preferences': behavior.download_preferences, + 'interaction_patterns': behavior.interaction_patterns + } + + return success_response({ + 'preferences': preferences + }) + + elif request.method == 'PUT': + # Update preferences + data = request.get_json() + + if not data: + return error_response("Request body is required", 400) + + # Update user behavior with preferences + interaction_data = { + 'type': 'preferences_update', + 'data': data + } + + await advanced_ux_service.update_user_behavior(user_id, interaction_data) + + return success_response({ + 'message': 'Preferences updated successfully' + }) + + except Exception as e: + logger.error(f"Error handling personalization preferences: {e}") + return error_response("Internal server error", 500) diff --git a/src/swingmusic/api/audio_quality.py b/src/swingmusic/api/audio_quality.py new file mode 100644 index 00000000..32105c30 --- /dev/null +++ b/src/swingmusic/api/audio_quality.py @@ -0,0 +1,805 @@ +""" +Audio Quality Management API Endpoints + +This module provides REST API endpoints for the advanced audio quality control system, +including adaptive streaming, audio enhancement, quality analysis, and user preferences. +""" + +import logging +from typing import Dict, List, Optional, Any +from flask import Blueprint, request, jsonify, send_file +from flask_login import login_required, current_user + +from swingmusic.db import db +from swingmusic.services.audio_quality_manager import ( + audio_quality_manager, AudioQualitySettings, AudioFormat, QualityLevel, + SampleRate, BitDepth, SpatialAudioFormat +) +from swingmusic.utils.request import APIError, success_response, error_response +from swingmusic.utils.validators import validate_audio_file + +logger = logging.getLogger(__name__) + +audio_quality_bp = Blueprint('audio_quality', __name__, url_prefix='/api/audio-quality') + + +def get_current_user_id() -> int: + """Get current user ID from Flask-Login""" + return current_user.id if current_user.is_authenticated else None + + +@audio_quality_bp.route('/settings', methods=['GET']) +@login_required +async def get_quality_settings(): + """ + Get user's audio quality settings + """ + try: + settings = await audio_quality_manager._get_user_settings(get_current_user_id()) + return success_response({ + 'settings': { + 'streaming_quality': settings.streaming_quality.value, + 'adaptive_quality': settings.adaptive_quality, + 'network_aware_quality': settings.network_aware_quality, + 'device_specific_quality': settings.device_specific_quality, + 'download_format': settings.download_format.value, + 'download_bitrate': settings.download_bitrate, + 'download_sample_rate': settings.download_sample_rate.value, + 'download_bit_depth': settings.download_bit_depth.value, + 'enable_dolby_atmos': settings.enable_dolby_atmos, + 'enable_360_audio': settings.enable_360_audio, + 'spatial_audio_format': settings.spatial_audio_format.value, + 'enable_adaptive_eq': settings.enable_adaptive_eq, + 'enable_spatial_audio_processing': settings.enable_spatial_audio_processing, + 'enable_loudness_normalization': settings.enable_loudness_normalization, + 'target_loudness': settings.target_loudness, + 'enable_crossfade': settings.enable_crossfade, + 'crossfade_duration': settings.crossfade_duration, + 'enable_gapless_playback': settings.enable_gapless_playback, + 'enable_replaygain': settings.enable_replaygain, + 'prioritize_fidelity': settings.prioritize_fidelity, + 'prioritize_file_size': settings.prioritize_file_size, + 'prioritize_compatibility': settings.prioritize_compatibility, + 'custom_ffmpeg_params': settings.custom_ffmpeg_params or {}, + 'enable_experimental_codecs': settings.enable_experimental_codecs, + 'cache_transcoded_files': settings.cache_transcoded_files + } + }) + + except Exception as e: + logger.error(f"Error getting quality settings: {e}") + return error_response("Internal server error", 500) + + +@audio_quality_bp.route('/settings', methods=['POST']) +@login_required +async def update_quality_settings(): + """ + Update user's audio quality settings + + Request Body: + { + "streaming_quality": "lossless|high|medium|low|data_saver", + "adaptive_quality": true, + "network_aware_quality": true, + "device_specific_quality": true, + "download_format": "flac|mp3_320|mp3_256|aac_256|...", + "download_bitrate": 320, + "download_sample_rate": "44.1kHz|48kHz|96kHz|192kHz", + "download_bit_depth": "16bit|24bit|32bit", + "enable_dolby_atmos": false, + "enable_360_audio": false, + "spatial_audio_format": "stereo|binaural|dolby_atmos|...", + "enable_adaptive_eq": true, + "enable_spatial_audio_processing": false, + "enable_loudness_normalization": true, + "target_loudness": -14.0, + "enable_crossfade": false, + "crossfade_duration": 2.0, + "enable_gapless_playback": true, + "enable_replaygain": true, + "prioritize_fidelity": true, + "prioritize_file_size": false, + "prioritize_compatibility": false, + "custom_ffmpeg_params": {}, + "enable_experimental_codecs": false, + "cache_transcoded_files": true + } + """ + try: + data = request.get_json() + + if not data: + return error_response("Request body is required", 400) + + # Validate and convert settings + settings = AudioQualitySettings() + + # Streaming quality + if 'streaming_quality' in data: + try: + settings.streaming_quality = QualityLevel(data['streaming_quality']) + except ValueError: + return error_response("Invalid streaming quality", 400) + + # Boolean settings + for key in ['adaptive_quality', 'network_aware_quality', 'device_specific_quality', + 'enable_dolby_atmos', 'enable_360_audio', 'enable_adaptive_eq', + 'enable_spatial_audio_processing', 'enable_loudness_normalization', + 'enable_crossfade', 'enable_gapless_playback', 'enable_replaygain', + 'prioritize_fidelity', 'prioritize_file_size', 'prioritize_compatibility', + 'enable_experimental_codecs', 'cache_transcoded_files']: + if key in data: + setattr(settings, key, bool(data[key])) + + # Download format + if 'download_format' in data: + try: + settings.download_format = AudioFormat(data['download_format']) + except ValueError: + return error_response("Invalid download format", 400) + + # Numeric settings + if 'download_bitrate' in data: + bitrate = data['download_bitrate'] + if bitrate is not None and (not isinstance(bitrate, int) or bitrate < 0 or bitrate > 1000): + return error_response("Invalid download bitrate", 400) + settings.download_bitrate = bitrate + + if 'target_loudness' in data: + loudness = data['target_loudness'] + if not isinstance(loudness, (int, float)) or loudness < -70 or loudness > 0: + return error_response("Invalid target loudness", 400) + settings.target_loudness = float(loudness) + + if 'crossfade_duration' in data: + duration = data['crossfade_duration'] + if not isinstance(duration, (int, float)) or duration < 0 or duration > 10: + return error_response("Invalid crossfade duration", 400) + settings.crossfade_duration = float(duration) + + # Enum settings + if 'download_sample_rate' in data: + try: + settings.download_sample_rate = SampleRate(data['download_sample_rate']) + except ValueError: + return error_response("Invalid download sample rate", 400) + + if 'download_bit_depth' in data: + try: + settings.download_bit_depth = BitDepth(data['download_bit_depth']) + except ValueError: + return error_response("Invalid download bit depth", 400) + + if 'spatial_audio_format' in data: + try: + settings.spatial_audio_format = SpatialAudioFormat(data['spatial_audio_format']) + except ValueError: + return error_response("Invalid spatial audio format", 400) + + # Custom FFmpeg params + if 'custom_ffmpeg_params' in data: + if not isinstance(data['custom_ffmpeg_params'], dict): + return error_response("Custom FFmpeg params must be an object", 400) + settings.custom_ffmpeg_params = data['custom_ffmpeg_params'] + + # Update settings + success = await audio_quality_manager.update_user_settings(get_current_user_id(), settings) + + if success: + return success_response({ + 'message': 'Audio quality settings updated successfully', + 'settings': data + }) + else: + return error_response("Failed to update settings", 500) + + except Exception as e: + logger.error(f"Error updating quality settings: {e}") + return error_response("Internal server error", 500) + + +@audio_quality_bp.route('/optimal-streaming', methods=['GET']) +@login_required +async def get_optimal_streaming_quality(): + """ + Get optimal streaming quality based on current conditions + + Query Parameters: + - context: JSON string with additional context (battery, network, etc.) + """ + try: + context_str = request.args.get('context', '{}') + try: + context = json.loads(context_str) if context_str else {} + except json.JSONDecodeError: + context = {} + + optimal = await audio_quality_manager.get_optimal_streaming_quality( + get_current_user_id(), context + ) + + return success_response({ + 'optimal_quality': optimal, + 'context': context + }) + + except Exception as e: + logger.error(f"Error getting optimal streaming quality: {e}") + return error_response("Internal server error", 500) + + +@audio_quality_bp.route('/transcode', methods=['POST']) +@login_required +async def transcode_for_streaming(): + """ + Transcode audio file for optimal streaming + + Request Body: + { + "file_path": "/path/to/audio/file", + "context": {} + } + """ + try: + data = request.get_json() + + if not data or not data.get('file_path'): + return error_response("file_path is required", 400) + + file_path = data['file_path'] + context = data.get('context', {}) + + # Validate file + if not validate_audio_file(file_path): + return error_response("Invalid audio file", 400) + + # Transcode for streaming + transcoded_path = await audio_quality_manager.transcode_for_streaming( + file_path, get_current_user_id(), context + ) + + if transcoded_path: + return success_response({ + 'transcoded_path': transcoded_path, + 'original_path': file_path + }) + else: + return error_response("Transcoding failed", 500) + + except Exception as e: + logger.error(f"Error transcoding for streaming: {e}") + return error_response("Internal server error", 500) + + +@audio_quality_bp.route('/analyze', methods=['POST']) +@login_required +async def analyze_audio_file(): + """ + Analyze audio file for quality metrics + + Request Body: + { + "file_path": "/path/to/audio/file" + } + """ + try: + data = request.get_json() + + if not data or not data.get('file_path'): + return error_response("file_path is required", 400) + + file_path = data['file_path'] + + # Validate file + if not validate_audio_file(file_path): + return error_response("Invalid audio file", 400) + + # Analyze file + analysis = await audio_quality_manager.analyze_audio_file(file_path) + + return success_response({ + 'analysis': { + 'file_path': analysis.file_path, + 'format': analysis.format, + 'duration': analysis.duration, + 'sample_rate': analysis.sample_rate, + 'bit_depth': analysis.bit_depth, + 'bitrate': analysis.bitrate, + 'channels': analysis.channels, + 'codec': analysis.codec, + 'dynamic_range': analysis.dynamic_range, + 'peak_level': analysis.peak_level, + 'rms_level': analysis.rms_level, + 'loudness': analysis.loudness, + 'frequency_response': analysis.frequency_response, + 'spectral_centroid': analysis.spectral_centroid, + 'spectral_rolloff': analysis.spectral_rolloff, + 'signal_to_noise_ratio': analysis.signal_to_noise_ratio, + 'total_harmonic_distortion': analysis.total_harmonic_distortion, + 'detected_genre': analysis.detected_genre, + 'acoustic_features': analysis.acoustic_features or {} + } + }) + + except Exception as e: + logger.error(f"Error analyzing audio file: {e}") + return error_response("Internal server error", 500) + + +@audio_quality_bp.route('/compare', methods=['POST']) +@login_required +async def compare_quality_formats(): + """ + Compare quality across different audio formats + + Request Body: + { + "file_path": "/path/to/audio/file", + "formats": ["flac", "mp3_320", "mp3_256", "aac_256"] + } + """ + try: + data = request.get_json() + + if not data or not data.get('file_path'): + return error_response("file_path is required", 400) + + file_path = data['file_path'] + formats = data.get('formats', ['flac', 'mp3_320']) + + # Validate file + if not validate_audio_file(file_path): + return error_response("Invalid audio file", 400) + + # Convert format strings to enum + format_enums = [] + for format_str in formats: + try: + format_enums.append(AudioFormat(format_str)) + except ValueError: + return error_response(f"Invalid format: {format_str}", 400) + + # Compare formats + comparison = await audio_quality_manager.compare_quality_formats( + file_path, format_enums + ) + + return success_response({ + 'comparison': { + 'original_file': comparison.original_file, + 'formats': comparison.formats, + 'size_difference': comparison.size_difference, + 'quality_score': comparison.quality_score, + 'transparency_score': comparison.transparency_score, + 'recommended_format': comparison.recommended_format, + 'recommended_reason': comparison.recommended_reason + } + }) + + except Exception as e: + logger.error(f"Error comparing quality formats: {e}") + return error_response("Internal server error", 500) + + +@audio_quality_bp.route('/enhance', methods=['POST']) +@login_required +async def enhance_audio(): + """ + Apply audio enhancements to a file + + Request Body: + { + "input_path": "/path/to/input/file", + "output_path": "/path/to/output/file", + "enhancements": { + "enable_loudness_normalization": true, + "target_loudness": -14.0, + "enable_adaptive_eq": true, + "enable_spatial_audio_processing": false, + "spatial_audio_format": "stereo" + } + } + """ + try: + data = request.get_json() + + if not data or not data.get('input_path') or not data.get('output_path'): + return error_response("input_path and output_path are required", 400) + + input_path = data['input_path'] + output_path = data['output_path'] + enhancements = data.get('enhancements', {}) + + # Validate files + if not validate_audio_file(input_path): + return error_response("Invalid input audio file", 400) + + # Build settings + settings = AudioQualitySettings() + + # Apply enhancement settings + for key, value in enhancements.items(): + if hasattr(settings, key): + setattr(settings, key, value) + + # Apply enhancements + success = await audio_quality_manager.enhancement_service.apply_enhancements( + input_path, output_path, settings + ) + + if success: + return success_response({ + 'message': 'Audio enhancements applied successfully', + 'input_path': input_path, + 'output_path': output_path, + 'enhancements': enhancements + }) + else: + return error_response("Audio enhancement failed", 500) + + except Exception as e: + logger.error(f"Error enhancing audio: {e}") + return error_response("Internal server error", 500) + + +@audio_quality_bp.route('/formats', methods=['GET']) +@login_required +async def get_supported_formats(): + """ + Get list of supported audio formats and their capabilities + """ + try: + formats = { + 'lossless': { + 'flac': { + 'name': 'FLAC', + 'description': 'Free Lossless Audio Codec', + 'extension': '.flac', + 'max_bitrate': None, + 'sample_rates': ['44.1kHz', '48kHz', '96kHz', '192kHz'], + 'bit_depths': ['16bit', '24bit'], + 'channels': ['mono', 'stereo', '5.1', '7.1'], + 'compression': 'lossless', + 'compatibility': 'high' + }, + 'alac': { + 'name': 'ALAC', + 'description': 'Apple Lossless Audio Codec', + 'extension': '.m4a', + 'max_bitrate': None, + 'sample_rates': ['44.1kHz', '48kHz', '96kHz'], + 'bit_depths': ['16bit', '24bit'], + 'channels': ['mono', 'stereo', '5.1'], + 'compression': 'lossless', + 'compatibility': 'medium' # Apple ecosystem + }, + 'wav': { + 'name': 'WAV', + 'description': 'Waveform Audio File Format', + 'extension': '.wav', + 'max_bitrate': None, + 'sample_rates': ['44.1kHz', '48kHz', '96kHz', '192kHz'], + 'bit_depths': ['16bit', '24bit', '32bit'], + 'channels': ['mono', 'stereo', '5.1', '7.1'], + 'compression': 'none', + 'compatibility': 'high' + } + }, + 'lossy': { + 'mp3_320': { + 'name': 'MP3 320kbps', + 'description': 'MPEG Audio Layer 3 at 320kbps', + 'extension': '.mp3', + 'max_bitrate': 320, + 'sample_rates': ['44.1kHz', '48kHz'], + 'bit_depths': ['16bit'], + 'channels': ['stereo'], + 'compression': 'lossy', + 'compatibility': 'very_high' + }, + 'mp3_256': { + 'name': 'MP3 256kbps', + 'description': 'MPEG Audio Layer 3 at 256kbps', + 'extension': '.mp3', + 'max_bitrate': 256, + 'sample_rates': ['44.1kHz', '48kHz'], + 'bit_depths': ['16bit'], + 'channels': ['stereo'], + 'compression': 'lossy', + 'compatibility': 'very_high' + }, + 'mp3_192': { + 'name': 'MP3 192kbps', + 'description': 'MPEG Audio Layer 3 at 192kbps', + 'extension': '.mp3', + 'max_bitrate': 192, + 'sample_rates': ['44.1kHz', '48kHz'], + 'bit_depths': ['16bit'], + 'channels': ['stereo'], + 'compression': 'lossy', + 'compatibility': 'very_high' + }, + 'mp3_128': { + 'name': 'MP3 128kbps', + 'description': 'MPEG Audio Layer 3 at 128kbps', + 'extension': '.mp3', + 'max_bitrate': 128, + 'sample_rates': ['44.1kHz', '48kHz'], + 'bit_depths': ['16bit'], + 'channels': ['stereo'], + 'compression': 'lossy', + 'compatibility': 'very_high' + }, + 'aac_256': { + 'name': 'AAC 256kbps', + 'description': 'Advanced Audio Coding at 256kbps', + 'extension': '.m4a', + 'max_bitrate': 256, + 'sample_rates': ['44.1kHz', '48kHz'], + 'bit_depths': ['16bit'], + 'channels': ['stereo'], + 'compression': 'lossy', + 'compatibility': 'high' + }, + 'aac_192': { + 'name': 'AAC 192kbps', + 'description': 'Advanced Audio Coding at 192kbps', + 'extension': '.m4a', + 'max_bitrate': 192, + 'sample_rates': ['44.1kHz', '48kHz'], + 'bit_depths': ['16bit'], + 'channels': ['stereo'], + 'compression': 'lossy', + 'compatibility': 'high' + }, + 'aac_128': { + 'name': 'AAC 128kbps', + 'description': 'Advanced Audio Coding at 128kbps', + 'extension': '.m4a', + 'max_bitrate': 128, + 'sample_rates': ['44.1kHz', '48kHz'], + 'bit_depths': ['16bit'], + 'channels': ['stereo'], + 'compression': 'lossy', + 'compatibility': 'high' + }, + 'ogg_vorbis': { + 'name': 'Ogg Vorbis', + 'description': 'Ogg Vorbis compressed audio', + 'extension': '.ogg', + 'max_bitrate': 500, + 'sample_rates': ['44.1kHz', '48kHz', '96kHz'], + 'bit_depths': ['16bit', '24bit'], + 'channels': ['mono', 'stereo', '5.1'], + 'compression': 'lossy', + 'compatibility': 'medium' + }, + 'ogg_opus': { + 'name': 'Opus', + 'description': 'Opus audio codec', + 'extension': '.opus', + 'max_bitrate': 510, + 'sample_rates': ['48kHz'], + 'bit_depths': ['16bit'], + 'channels': ['mono', 'stereo'], + 'compression': 'lossy', + 'compatibility': 'medium' + } + } + } + + return success_response({'formats': formats}) + + except Exception as e: + logger.error(f"Error getting supported formats: {e}") + return error_response("Internal server error", 500) + + +@audio_quality_bp.route('/quality-presets', methods=['GET']) +@login_required +async def get_quality_presets(): + """ + Get predefined quality presets for different use cases + """ + try: + presets = { + 'audiophile': { + 'name': 'Audiophile', + 'description': 'Maximum quality for critical listening', + 'settings': { + 'streaming_quality': 'lossless', + 'download_format': 'flac', + 'download_sample_rate': '96kHz', + 'download_bit_depth': '24bit', + 'enable_loudness_normalization': false, + 'prioritize_fidelity': true + } + }, + 'portable': { + 'name': 'Portable', + 'description': 'Balanced quality for mobile devices', + 'settings': { + 'streaming_quality': 'high', + 'download_format': 'aac_256', + 'adaptive_quality': true, + 'network_aware_quality': true, + 'device_specific_quality': true, + 'enable_loudness_normalization': true, + 'prioritize_compatibility': true + } + }, + 'data_saver': { + 'name': 'Data Saver', + 'description': 'Minimal bandwidth usage', + 'settings': { + 'streaming_quality': 'data_saver', + 'download_format': 'mp3_128', + 'adaptive_quality': true, + 'network_aware_quality': true, + 'enable_loudness_normalization': true, + 'prioritize_file_size': true + } + }, + 'studio': { + 'name': 'Studio', + 'description': 'Professional quality for production', + 'settings': { + 'streaming_quality': 'lossless', + 'download_format': 'wav', + 'download_sample_rate': '192kHz', + 'download_bit_depth': '32bit', + 'enable_loudness_normalization': false, + 'prioritize_fidelity': true, + 'cache_transcoded_files': false + } + }, + 'gaming': { + 'name': 'Gaming', + 'description': 'Low latency with good quality', + 'settings': { + 'streaming_quality': 'medium', + 'download_format': 'mp3_256', + 'enable_crossfade': false, + 'enable_gapless_playback': true, + 'cache_transcoded_files': true + } + }, + 'podcast': { + 'name': 'Podcast', + 'description': 'Optimized for speech content', + 'settings': { + 'streaming_quality': 'medium', + 'download_format': 'aac_128', + 'enable_loudness_normalization': true, + 'target_loudness': -16.0, + 'enable_adaptive_eq': true, + 'prioritize_file_size': true + } + } + } + + return success_response({'presets': presets}) + + except Exception as e: + logger.error(f"Error getting quality presets: {e}") + return error_response("Internal server error", 500) + + +@audio_quality_bp.route('/apply-preset', methods=['POST']) +@login_required +async def apply_quality_preset(): + """ + Apply a quality preset to user settings + + Request Body: + { + "preset_name": "audiophile|portable|data_saver|studio|gaming|podcast" + } + """ + try: + data = request.get_json() + + if not data or not data.get('preset_name'): + return error_response("preset_name is required", 400) + + preset_name = data['preset_name'] + + # Get presets + presets_response = await get_quality_presets() + presets = presets_response[1].get_json()['presets'] + + if preset_name not in presets: + return error_response(f"Unknown preset: {preset_name}", 400) + + preset = presets[preset_name] + + # Apply preset settings + success = await audio_quality_manager.update_user_settings( + get_current_user_id(), + AudioQualitySettings(**preset['settings']) + ) + + if success: + return success_response({ + 'message': f'Applied {preset["name"]} preset successfully', + 'preset': preset, + 'settings': preset['settings'] + }) + else: + return error_response("Failed to apply preset", 500) + + except Exception as e: + logger.error(f"Error applying quality preset: {e}") + return error_response("Internal server error", 500) + + +@audio_quality_bp.route('/cache/clear', methods=['POST']) +@login_required +async def clear_quality_cache(): + """ + Clear audio quality analysis and transcoding cache + """ + try: + audio_quality_manager.clear_cache() + + return success_response({ + 'message': 'Audio quality cache cleared successfully' + }) + + except Exception as e: + logger.error(f"Error clearing quality cache: {e}") + return error_response("Internal server error", 500) + + +@audio_quality_bp.route('/network/status', methods=['GET']) +@login_required +async def get_network_status(): + """ + Get current network status for quality optimization + """ + try: + from swingmusic.services.audio_quality_manager import NetworkMonitor + + network_monitor = NetworkMonitor() + status = await network_monitor.get_network_status() + + return success_response({ + 'network_status': status + }) + + except Exception as e: + logger.error(f"Error getting network status: {e}") + return error_response("Internal server error", 500) + + +@audio_quality_bp.route('/device/info', methods=['GET']) +@login_required +async def get_device_info(): + """ + Get device information for quality optimization + """ + try: + from swingmusic.services.audio_quality_manager import DeviceDetector + + device_detector = DeviceDetector() + device_info = device_detector.get_device_info() + + return success_response({ + 'device_info': device_info + }) + + except Exception as e: + logger.error(f"Error getting device info: {e}") + return error_response("Internal server error", 500) + + +# Error handlers +@audio_quality_bp.errorhandler(404) +def not_found(error): + return error_response("Endpoint not found", 404) + + +@audio_quality_bp.errorhandler(500) +def internal_error(error): + return error_response("Internal server error", 500) diff --git a/src/swingmusic/api/enhanced_search.py b/src/swingmusic/api/enhanced_search.py new file mode 100644 index 00000000..1953c794 --- /dev/null +++ b/src/swingmusic/api/enhanced_search.py @@ -0,0 +1,463 @@ +""" +Enhanced Search API for SwingMusic +Integrates global music catalog search with existing local search +""" + +from flask import Blueprint, request, jsonify +from typing import Dict, List, Any, Optional +import asyncio + +from swingmusic.services.music_catalog import music_catalog_service +from swingmusic.api.search import search as local_search +from swingmusic import logger +from swingmusic.db.spotify import UserCatalogPreferencesTable + +# Create blueprint +enhanced_search_bp = Blueprint('enhanced_search', __name__, url_prefix='/api/search') + + +@enhanced_search_bp.route('/global', methods=['POST']) +def global_search(): + """ + Search across global music catalog (Spotify) + + Request body: + { + "query": "search query", + "type": "all|tracks|albums|artists|playlists", + "limit": 20, + "user_id": 1 + } + """ + try: + data = request.get_json() + if not data or not data.get('query'): + return jsonify({'error': 'Search query is required'}), 400 + + query = data['query'].strip() + search_type = data.get('type', 'all') + limit = min(data.get('limit', 20), 50) # Cap at 50 + user_id = data.get('user_id') + + # Get user preferences if available + user_prefs = None + if user_id: + user_prefs = UserCatalogPreferencesTable.get_or_create(user_id) + limit = min(limit, user_prefs.max_search_results) + + # Run async search + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + result = loop.run_until_complete( + music_catalog_service.search_global_catalog(query, search_type, limit) + ) + finally: + loop.close() + + # Filter based on user preferences + if user_prefs and not user_prefs.show_explicit: + result.tracks = [track for track in result.tracks if not track.explicit] + result.albums = [album for album in result.albums if not album.explicit] + + # Convert to dict for JSON response + response_data = { + 'query': result.query, + 'total': result.total, + 'tracks': [_catalog_item_to_dict(track) for track in result.tracks], + 'albums': [_catalog_item_to_dict(album) for album in result.albums], + 'artists': [_catalog_item_to_dict(artist) for artist in result.artists], + 'playlists': [_catalog_item_to_dict(playlist) for playlist in result.playlists], + 'source': 'global_catalog', + 'cache_info': { + 'from_cache': True, # TODO: Implement cache detection + 'expires_at': None + } + } + + return jsonify(response_data) + + except Exception as e: + logger.error(f"Error in global search: {e}") + return jsonify({'error': 'Search failed'}), 500 + + +@enhanced_search_bp.route('/combined', methods=['POST']) +def combined_search(): + """ + Search both local library and global catalog + + Request body: + { + "query": "search query", + "include_local": true, + "include_global": true, + "type": "all|tracks|albums|artists", + "limit": 20, + "user_id": 1 + } + """ + try: + data = request.get_json() + if not data or not data.get('query'): + return jsonify({'error': 'Search query is required'}), 400 + + query = data['query'].strip() + include_local = data.get('include_local', True) + include_global = data.get('include_global', True) + search_type = data.get('type', 'all') + limit = min(data.get('limit', 20), 50) + user_id = data.get('user_id') + + results = { + 'query': query, + 'local': {'tracks': [], 'albums': [], 'artists': []}, + 'global': {'tracks': [], 'albums': [], 'artists': [], 'playlists': []}, + 'total': 0 + } + + # Search local library + if include_local: + try: + # Use existing local search + local_results = local_search(query, search_type) + results['local'] = local_results if local_results else {'tracks': [], 'albums': [], 'artists': []} + except Exception as e: + logger.error(f"Error in local search: {e}") + + # Search global catalog + if include_global: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + global_results = loop.run_until_complete( + music_catalog_service.search_global_catalog(query, search_type, limit) + ) + + # Filter based on user preferences + user_prefs = None + if user_id: + user_prefs = UserCatalogPreferencesTable.get_or_create(user_id) + if not user_prefs.show_explicit: + global_results.tracks = [track for track in global_results.tracks if not track.explicit] + global_results.albums = [album for album in global_results.albums if not album.explicit] + + results['global'] = { + 'tracks': [_catalog_item_to_dict(track) for track in global_results.tracks], + 'albums': [_catalog_item_to_dict(album) for album in global_results.albums], + 'artists': [_catalog_item_to_dict(artist) for artist in global_results.artists], + 'playlists': [_catalog_item_to_dict(playlist) for playlist in global_results.playlists] + } + + finally: + loop.close() + + # Calculate total + results['total'] = ( + len(results['local'].get('tracks', [])) + + len(results['local'].get('albums', [])) + + len(results['local'].get('artists', [])) + + len(results['global'].get('tracks', [])) + + len(results['global'].get('albums', [])) + + len(results['global'].get('artists', [])) + + len(results['global'].get('playlists', [])) + ) + + return jsonify(results) + + except Exception as e: + logger.error(f"Error in combined search: {e}") + return jsonify({'error': 'Search failed'}), 500 + + +@enhanced_search_bp.route('/suggestions', methods=['GET']) +def search_suggestions(): + """ + Get search suggestions based on query and user preferences + + Query parameters: + - q: search query + - type: tracks|albums|artists|all + - limit: number of suggestions (default 10) + - user_id: user ID for preferences + """ + try: + query = request.args.get('q', '').strip() + if not query or len(query) < 2: + return jsonify({'suggestions': []}) + + search_type = request.args.get('type', 'all') + limit = min(int(request.args.get('limit', 10)), 20) + user_id = request.args.get('user_id') + + # Get user preferences + user_prefs = None + if user_id: + user_prefs = UserCatalogPreferencesTable.get_or_create(user_id) + limit = min(limit, user_prefs.max_search_results) + + # Search cached items for fast suggestions + item_types = None + if search_type != 'all': + item_types = [search_type] + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + # For suggestions, search both cache and live + suggestions = [] + + # Search cached items first (fast) + from swingmusic.db.spotify import GlobalCatalogCacheTable + cached_items = GlobalCatalogCacheTable.search_cached(query, item_types, limit) + + for item in cached_items: + if user_prefs and not user_prefs.show_explicit and item.explicit: + continue + + suggestion = { + 'id': item.spotify_id, + 'type': item.item_type, + 'title': item.title, + 'artist': item.artist, + 'album': item.album, + 'image_url': item.image_url, + 'popularity': item.popularity, + 'source': 'cache' + } + suggestions.append(suggestion) + + # If we need more suggestions, search global catalog + if len(suggestions) < limit: + remaining = limit - len(suggestions) + global_results = loop.run_until_complete( + music_catalog_service.search_global_catalog(query, search_type, remaining) + ) + + for track in global_results.tracks[:remaining]: + if user_prefs and not user_prefs.show_explicit and track.explicit: + continue + + suggestion = { + 'id': track.spotify_id, + 'type': 'track', + 'title': track.title, + 'artist': track.artist, + 'album': track.album, + 'image_url': track.image_url, + 'popularity': track.popularity, + 'source': 'global' + } + suggestions.append(suggestion) + + return jsonify({'suggestions': suggestions[:limit]}) + + finally: + loop.close() + + except Exception as e: + logger.error(f"Error in search suggestions: {e}") + return jsonify({'suggestions': []}) + + +@enhanced_search_bp.route('/artist/', methods=['GET']) +def get_artist_info(artist_id: str): + """ + Get comprehensive artist information including top tracks and albums + + Path parameters: + - artist_id: Spotify artist ID + + Query parameters: + - user_id: user ID for preferences + """ + try: + user_id = request.args.get('user_id') + + # Get user preferences + user_prefs = None + if user_id: + user_prefs = UserCatalogPreferencesTable.get_or_create(user_id) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + artist_info = loop.run_until_complete( + music_catalog_service.get_artist_info(artist_id) + ) + + if not artist_info: + return jsonify({'error': 'Artist not found'}), 404 + + # Filter based on user preferences + if user_prefs and not user_prefs.show_explicit: + artist_info.top_tracks = [ + track for track in artist_info.top_tracks or [] if not track.explicit + ] + artist_info.albums = [ + album for album in artist_info.albums or [] if not album.explicit + ] + + response_data = { + 'spotify_id': artist_info.spotify_id, + 'name': artist_info.name, + 'image_url': artist_info.image_url, + 'followers': artist_info.followers, + 'popularity': artist_info.popularity, + 'genres': artist_info.genres or [], + 'top_tracks': [_catalog_item_to_dict(track) for track in (artist_info.top_tracks or [])], + 'albums': [_catalog_item_to_dict(album) for album in (artist_info.albums or [])], + 'related_artists': artist_info.related_artists or [] + } + + return jsonify(response_data) + + finally: + loop.close() + + except Exception as e: + logger.error(f"Error getting artist info: {e}") + return jsonify({'error': 'Failed to get artist info'}), 500 + + +@enhanced_search_bp.route('/album/', methods=['GET']) +def get_album_details(album_id: str): + """ + Get detailed album information with tracklist + + Path parameters: + - album_id: Spotify album ID + + Query parameters: + - user_id: user ID for preferences + """ + try: + user_id = request.args.get('user_id') + + # Get user preferences + user_prefs = None + if user_id: + user_prefs = UserCatalogPreferencesTable.get_or_create(user_id) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + album = loop.run_until_complete( + music_catalog_service.get_album_details(album_id) + ) + + if not album: + return jsonify({'error': 'Album not found'}), 404 + + # Filter based on user preferences + if user_prefs and not user_prefs.show_explicit and album.explicit: + return jsonify({'error': 'Explicit content filtered'}), 403 + + response_data = _catalog_item_to_dict(album) + + # Add tracklist if available in data + if album.data and 'tracks' in album.data: + response_data['tracks'] = [ + _catalog_item_to_dict(track) for track in album.data['tracks'] + ] + + return jsonify(response_data) + + finally: + loop.close() + + except Exception as e: + logger.error(f"Error getting album details: {e}") + return jsonify({'error': 'Failed to get album details'}), 500 + + +@enhanced_search_bp.route('/preferences/', methods=['GET', 'POST']) +def user_preferences(user_id: int): + """Get or update user catalog search preferences""" + try: + if request.method == 'GET': + prefs = UserCatalogPreferencesTable.get_or_create(user_id) + return jsonify({ + 'user_id': prefs.user_id, + 'show_explicit': prefs.show_explicit, + 'default_quality': prefs.default_quality, + 'auto_download': prefs.auto_download, + 'show_suggestions': prefs.show_suggestions, + 'preferred_genres': prefs.preferred_genres or [], + 'excluded_genres': prefs.excluded_genres or [], + 'max_search_results': prefs.max_search_results, + 'cache_ttl_preference': prefs.cache_ttl_preference + }) + + elif request.method == 'POST': + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + # Update only provided fields + update_data = {} + allowed_fields = [ + 'show_explicit', 'default_quality', 'auto_download', + 'show_suggestions', 'preferred_genres', 'excluded_genres', + 'max_search_results', 'cache_ttl_preference' + ] + + for field in allowed_fields: + if field in data: + update_data[field] = data[field] + + if update_data: + UserCatalogPreferencesTable.update_preferences(user_id, update_data) + + return jsonify({'message': 'Preferences updated successfully'}) + + except Exception as e: + logger.error(f"Error handling user preferences: {e}") + return jsonify({'error': 'Failed to handle preferences'}), 500 + + +def _catalog_item_to_dict(item) -> Dict[str, Any]: + """Convert CatalogItem to dictionary for JSON response""" + if hasattr(item, '__dict__'): + # It's a dataclass instance + return { + 'spotify_id': item.spotify_id, + 'type': item.item_type.value if hasattr(item.item_type, 'value') else str(item.item_type), + 'title': item.title, + 'artist': item.artist, + 'album': item.album, + 'duration_ms': item.duration_ms, + 'popularity': item.popularity, + 'preview_url': item.preview_url, + 'image_url': item.image_url, + 'release_date': item.release_date, + 'explicit': item.explicit, + 'data': item.data + } + else: + # It's likely a database model + return { + 'spotify_id': getattr(item, 'spotify_id', None), + 'type': getattr(item, 'item_type', None), + 'title': getattr(item, 'title', None), + 'artist': getattr(item, 'artist', None), + 'album': getattr(item, 'album', None), + 'duration_ms': getattr(item, 'duration_ms', None), + 'popularity': getattr(item, 'popularity', None), + 'preview_url': getattr(item, 'preview_url', None), + 'image_url': getattr(item, 'image_url', None), + 'release_date': getattr(item, 'release_date', None), + 'explicit': getattr(item, 'explicit', False), + 'data': getattr(item, 'data', None) + } + + +def register_enhanced_search_api(app): + """Register enhanced search API with Flask app""" + app.register_blueprint(enhanced_search_bp) + logger.info("Enhanced search API registered") diff --git a/src/swingmusic/api/lyrics.py b/src/swingmusic/api/lyrics.py index 1ade3476..dcd24430 100644 --- a/src/swingmusic/api/lyrics.py +++ b/src/swingmusic/api/lyrics.py @@ -8,7 +8,9 @@ from swingmusic.lib.lyrics import ( get_lyrics_file, get_lyrics_from_duplicates, get_lyrics_from_tags, + Lyrics as Lyrics_class, ) +from swingmusic.plugins.lyrics import Lyrics bp_tag = Tag(name="Lyrics", description="Get lyrics") api = APIBlueprint("lyrics", __name__, url_prefix="/lyrics", abp_tags=[bp_tag]) @@ -50,6 +52,51 @@ def send_lyrics(body: SendLyricsBody): # check lyrics plugins + if not lyrics: + try: + # Get track metadata for plugin search + entry = TrackStore.trackhashmap.get(trackhash, None) + if entry and len(entry.tracks) > 0: + track = entry.tracks[0] # Use first track for metadata + title = getattr(track, 'title', '') or '' + artist = '' + if hasattr(track, 'artists') and track.artists: + artist = track.artists[0].name if hasattr(track.artists[0], 'name') else str(track.artists[0]) + album = '' + if hasattr(track, 'album') and track.album: + album = track.album.name if hasattr(track.album, 'name') else str(track.album) + + # Only proceed if we have basic metadata + if title and artist: + # Initialize lyrics plugin + lyrics_plugin = Lyrics() + if lyrics_plugin.enabled: + # Search for lyrics using plugin + search_results = lyrics_plugin.search_lyrics_by_title_and_artist(title, artist) + if search_results and len(search_results) > 0: + # Use first result or perfect match + perfect_match = search_results[0] + + # Try to find perfect match by comparing title and album + if album: + for result in search_results: + result_title = result.get("title", "").lower() + result_album = result.get("album", "").lower() + if (result_title == title.lower() and + result_album == album.lower()): + perfect_match = result + break + + # Download lyrics using track ID + track_id = perfect_match.get("track_id") + if track_id: + lrc_content = lyrics_plugin.download_lyrics(track_id, filepath) + if lrc_content and len(lrc_content.strip()) > 0: + lyrics = Lyrics_class(lrc_content) + except Exception as e: + # Log error but don't break the lyrics fetching process + # In production, you might want to log this error + pass if not lyrics: return {"error": "No lyrics found"} diff --git a/src/swingmusic/api/mobile_offline.py b/src/swingmusic/api/mobile_offline.py new file mode 100644 index 00000000..d3f311ae --- /dev/null +++ b/src/swingmusic/api/mobile_offline.py @@ -0,0 +1,621 @@ +""" +Mobile Offline Mode API Endpoints + +This module provides REST API endpoints for mobile offline functionality, +including device management, sync operations, and offline library access. +""" + +import logging +from datetime import datetime +from typing import Dict, List, Optional, Any +from flask import Blueprint, request, jsonify +from flask_login import login_required, current_user + +from swingmusic.db import db +from swingmusic.services.mobile_offline_service import mobile_offline_service, OfflineQuality, SyncStatus +from swingmusic.utils.request import APIError, success_response, error_response +from swingmusic.utils.validators import validate_device_info, validate_track_ids + +logger = logging.getLogger(__name__) + +mobile_offline_bp = Blueprint('mobile_offline', __name__, url_prefix='/api/mobile-offline') + + +def get_current_user_id() -> int: + """Get current user ID from Flask-Login""" + return current_user.id if current_user.is_authenticated else None + + +@mobile_offline_bp.route('/devices/register', methods=['POST']) +@login_required +async def register_device(): + """ + Register a new mobile device for offline sync + + Request Body: + { + "name": "iPhone 14 Pro", + "type": "ios", + "storage_capacity": 256000000000, + "available_storage": 128000000000, + "preferences": { + "auto_sync": true, + "wifi_only": true, + "quality": "balanced" + } + } + """ + try: + user_id = get_current_user_id() + data = request.get_json() + + if not data: + return error_response("Request body is required", 400) + + # Validate device information + device_info = validate_device_info(data) + + # Register device + device = await mobile_offline_service.register_device(user_id, device_info) + + return success_response({ + 'message': 'Device registered successfully', + 'device': { + 'device_id': device.device_id, + 'name': device.device_name, + 'type': device.device_type, + 'storage_capacity': device.storage_capacity, + 'available_storage': device.available_storage, + 'offline_quality': device.offline_quality.value, + 'auto_sync_enabled': device.auto_sync_enabled, + 'sync_status': device.sync_status.value, + 'created_at': device.created_at.isoformat() + } + }) + + except Exception as e: + logger.error(f"Error registering device: {e}") + return error_response("Internal server error", 500) + + +@mobile_offline_bp.route('/devices', methods=['GET']) +@login_required +async def get_user_devices(): + """ + Get all registered devices for the current user + """ + try: + user_id = get_current_user_id() + + # This would get all devices for the user from database + # For now, return empty list as placeholder + devices = [] + + return success_response({ + 'devices': devices, + 'total_count': len(devices) + }) + + except Exception as e: + logger.error(f"Error getting user devices: {e}") + return error_response("Internal server error", 500) + + +@mobile_offline_bp.route('/devices/', methods=['GET']) +@login_required +async def get_device_info(device_id: str): + """ + Get specific device information + + Path Parameters: + - device_id: Device ID + """ + try: + user_id = get_current_user_id() + + device = await mobile_offline_service._get_device(device_id, user_id) + if not device: + return error_response("Device not found", 404) + + return success_response({ + 'device': { + 'device_id': device.device_id, + 'name': device.device_name, + 'type': device.device_type, + 'storage_capacity': device.storage_capacity, + 'available_storage': device.available_storage, + 'last_sync': device.last_sync.isoformat() if device.last_sync else None, + 'sync_status': device.sync_status.value, + 'offline_quality': device.offline_quality.value, + 'auto_sync_enabled': device.auto_sync_enabled, + 'sync_preferences': device.sync_preferences, + 'created_at': device.created_at.isoformat(), + 'updated_at': device.updated_at.isoformat() + } + }) + + except Exception as e: + logger.error(f"Error getting device info: {e}") + return error_response("Internal server error", 500) + + +@mobile_offline_bp.route('/devices//settings', methods=['PUT']) +@login_required +async def update_device_settings(device_id: str): + """ + Update device settings + + Path Parameters: + - device_id: Device ID + + Request Body: + { + "offline_quality": "high_quality", + "auto_sync_enabled": true, + "sync_preferences": { + "wifi_only": true, + "auto_cleanup": true + }, + "available_storage": 120000000000 + } + """ + try: + user_id = get_current_user_id() + data = request.get_json() + + if not data: + return error_response("Request body is required", 400) + + # Validate settings + if 'offline_quality' in data: + try: + OfflineQuality(data['offline_quality']) + except ValueError: + return error_response("Invalid offline quality", 400) + + # Update settings + success = await mobile_offline_service.update_device_settings(user_id, device_id, data) + + if not success: + return error_response("Failed to update device settings", 500) + + return success_response({ + 'message': 'Device settings updated successfully' + }) + + except Exception as e: + logger.error(f"Error updating device settings: {e}") + return error_response("Internal server error", 500) + + +@mobile_offline_bp.route('/devices//offline-library', methods=['GET']) +@login_required +async def get_offline_library(device_id: str): + """ + Get offline library for device + + Path Parameters: + - device_id: Device ID + + Query Parameters: + - include_tracks: Include track details (default: true) + - include_queue: Include sync queue status (default: true) + - include_storage: Include storage usage (default: true) + """ + try: + user_id = get_current_user_id() + + # Parse include flags + include_flags = { + 'tracks': request.args.get('include_tracks', 'true').lower() == 'true', + 'queue': request.args.get('include_queue', 'true').lower() == 'true', + 'storage': request.args.get('include_storage', 'true').lower() == 'true' + } + + # Get offline library + library_data = await mobile_offline_service.get_offline_library(user_id, device_id) + + # Build response based on include flags + response_data = { + 'device': library_data['device'], + 'last_sync': library_data['last_sync'], + 'sync_status': library_data['sync_status'] + } + + if include_flags['tracks']: + response_data['offline_tracks'] = library_data['offline_tracks'] + + if include_flags['queue']: + response_data['sync_queue'] = library_data['sync_queue'] + + if include_flags['storage']: + response_data['storage_usage'] = library_data['storage_usage'] + + return success_response({ + 'offline_library': response_data + }) + + except Exception as e: + logger.error(f"Error getting offline library: {e}") + return error_response("Internal server error", 500) + + +@mobile_offline_bp.route('/devices//add-tracks', methods=['POST']) +@login_required +async def add_tracks_to_offline(device_id: str): + """ + Add tracks to offline library + + Path Parameters: + - device_id: Device ID + + Request Body: + { + "track_ids": ["track1", "track2", "track3"], + "quality": "high_quality" + } + """ + try: + user_id = get_current_user_id() + data = request.get_json() + + if not data: + return error_response("Request body is required", 400) + + track_ids = data.get('track_ids', []) + if not track_ids: + return error_response("track_ids are required", 400) + + # Validate track IDs + validate_track_ids(track_ids) + + # Parse quality + quality = None + if 'quality' in data: + try: + quality = OfflineQuality(data['quality']) + except ValueError: + return error_response("Invalid quality", 400) + + # Add tracks to offline library + queue_items = await mobile_offline_service.add_to_offline_library( + user_id, device_id, track_ids, quality + ) + + return success_response({ + 'message': f'Added {len(queue_items)} tracks to offline library', + 'queue_items': [ + { + 'queue_id': item.queue_id, + 'track_id': item.track_id, + 'priority': item.priority, + 'quality': item.quality, + 'status': item.status, + 'added_at': item.added_at.isoformat() + } + for item in queue_items + ] + }) + + except Exception as e: + logger.error(f"Error adding tracks to offline library: {e}") + return error_response("Internal server error", 500) + + +@mobile_offline_bp.route('/devices//sync-playlist/', methods=['POST']) +@login_required +async def sync_playlist_offline(device_id: str, playlist_id: str): + """ + Sync entire playlist for offline playback + + Path Parameters: + - device_id: Device ID + - playlist_id: Playlist ID + + Request Body: + { + "quality": "balanced" + } + """ + try: + user_id = get_current_user_id() + data = request.get_json() or {} + + # Parse quality + quality = None + if 'quality' in data: + try: + quality = OfflineQuality(data['quality']) + except ValueError: + return error_response("Invalid quality", 400) + + # Sync playlist + queue_items = await mobile_offline_service.sync_playlist_offline( + user_id, device_id, playlist_id, quality + ) + + return success_response({ + 'message': f'Playlist sync started with {len(queue_items)} tracks', + 'queue_items': [ + { + 'queue_id': item.queue_id, + 'track_id': item.track_id, + 'priority': item.priority, + 'quality': item.quality, + 'status': item.status, + 'added_at': item.added_at.isoformat() + } + for item in queue_items + ] + }) + + except Exception as e: + logger.error(f"Error syncing playlist offline: {e}") + return error_response("Internal server error", 500) + + +@mobile_offline_bp.route('/devices//remove-tracks', methods=['POST']) +@login_required +async def remove_tracks_from_offline(device_id: str): + """ + Remove tracks from offline library + + Path Parameters: + - device_id: Device ID + + Request Body: + { + "track_ids": ["track1", "track2", "track3"] + } + """ + try: + user_id = get_current_user_id() + data = request.get_json() + + if not data: + return error_response("Request body is required", 400) + + track_ids = data.get('track_ids', []) + if not track_ids: + return error_response("track_ids are required", 400) + + # Validate track IDs + validate_track_ids(track_ids) + + # Remove tracks + success = await mobile_offline_service.remove_from_offline_library( + user_id, device_id, track_ids + ) + + if not success: + return error_response("Failed to remove tracks from offline library", 500) + + return success_response({ + 'message': f'Removed {len(track_ids)} tracks from offline library' + }) + + except Exception as e: + logger.error(f"Error removing tracks from offline library: {e}") + return error_response("Internal server error", 500) + + +@mobile_offline_bp.route('/devices//sync-progress', methods=['GET']) +@login_required +async def get_sync_progress(device_id: str): + """ + Get sync progress for device + + Path Parameters: + - device_id: Device ID + """ + try: + user_id = get_current_user_id() + + progress_data = await mobile_offline_service.get_sync_progress(user_id, device_id) + + return success_response({ + 'sync_progress': progress_data + }) + + except Exception as e: + logger.error(f"Error getting sync progress: {e}") + return error_response("Internal server error", 500) + + +@mobile_offline_bp.route('/devices//force-sync', methods=['POST']) +@login_required +async def force_sync_now(device_id: str): + """ + Force immediate sync for device + + Path Parameters: + - device_id: Device ID + """ + try: + user_id = get_current_user_id() + + success = await mobile_offline_service.force_sync_now(user_id, device_id) + + if not success: + return error_response("Failed to force sync", 500) + + return success_response({ + 'message': 'Sync started successfully' + }) + + except Exception as e: + logger.error(f"Error forcing sync: {e}") + return error_response("Internal server error", 500) + + +@mobile_offline_bp.route('/devices//storage-info', methods=['GET']) +@login_required +async def get_storage_info(device_id: str): + """ + Get detailed storage information for device + + Path Parameters: + - device_id: Device ID + """ + try: + user_id = get_current_user_id() + + # Get device info + device = await mobile_offline_service._get_device(device_id, user_id) + if not device: + return error_response("Device not found", 404) + + # Get storage usage + storage_usage = await mobile_offline_service._get_storage_usage(device_id) + + # Calculate additional info + usage_percentage = (storage_usage.used_space / storage_usage.total_capacity * 100) if storage_usage.total_capacity > 0 else 0 + + return success_response({ + 'storage_info': { + 'total_capacity': storage_usage.total_capacity, + 'used_space': storage_usage.used_space, + 'available_space': storage_usage.available_space, + 'usage_percentage': round(usage_percentage, 2), + 'offline_tracks_count': storage_usage.offline_tracks_count, + 'offline_tracks_size': storage_usage.offline_tracks_size, + 'other_data_size': storage_usage.other_data_size, + 'quality_breakdown': storage_usage.quality_breakdown, + 'needs_cleanup': usage_percentage > 90, + 'recommendations': _get_storage_recommendations(usage_percentage, storage_usage) + } + }) + + except Exception as e: + logger.error(f"Error getting storage info: {e}") + return error_response("Internal server error", 500) + + +@mobile_offline_bp.route('/devices//cleanup', methods=['POST']) +@login_required +async def cleanup_storage(device_id: str): + """ + Cleanup storage by removing old/unused content + + Path Parameters: + - device_id: Device ID + + Request Body: + { + "strategy": "least_played|oldest|all", + "free_space_bytes": 1000000000 + } + """ + try: + user_id = get_current_user_id() + data = request.get_json() + + if not data: + return error_response("Request body is required", 400) + + strategy = data.get('strategy', 'least_played') + free_space_bytes = data.get('free_space_bytes', 0) + + # Validate strategy + valid_strategies = ['least_played', 'oldest', 'all'] + if strategy not in valid_strategies: + return error_response(f"Invalid strategy. Must be one of: {valid_strategies}", 400) + + # Perform cleanup + # This would implement the actual cleanup logic + freed_space = await mobile_offline_service._cleanup_old_content(device_id, free_space_bytes) + + return success_response({ + 'message': f'Cleanup completed', + 'freed_space': freed_space, + 'strategy_used': strategy + }) + + except Exception as e: + logger.error(f"Error during cleanup: {e}") + return error_response("Internal server error", 500) + + +@mobile_offline_bp.route('/quality-presets', methods=['GET']) +@login_required +async def get_quality_presets(): + """ + Get available quality presets for offline downloads + """ + try: + presets = { + 'space_saver': { + 'name': 'Space Saver', + 'description': 'Low quality, maximum storage efficiency', + 'estimated_size_per_track': '3MB', + 'recommended_for': 'Limited storage, large libraries', + 'formats': ['MP3 128kbps', 'AAC 128kbps'] + }, + 'balanced': { + 'name': 'Balanced', + 'description': 'Medium quality, good balance', + 'estimated_size_per_track': '6MB', + 'recommended_for': 'Most users, good quality', + 'formats': ['MP3 256kbps', 'AAC 256kbps'] + }, + 'high_quality': { + 'name': 'High Quality', + 'description': 'High quality, more storage usage', + 'estimated_size_per_track': '12MB', + 'recommended_for': 'Audiophiles, premium headphones', + 'formats': ['MP3 320kbps', 'AAC 320kbps', 'OGG Vorbis'] + }, + 'lossless': { + 'name': 'Lossless', + 'description': 'Lossless quality, maximum storage usage', + 'estimated_size_per_track': '30MB', + 'recommended_for': 'Critical listening, unlimited storage', + 'formats': ['FLAC', 'ALAC', 'WAV'] + } + } + + return success_response({ + 'quality_presets': presets + }) + + except Exception as e: + logger.error(f"Error getting quality presets: {e}") + return error_response("Internal server error", 500) + + +def _get_storage_recommendations(usage_percentage: float, storage_usage) -> List[str]: + """Get storage recommendations based on usage""" + recommendations = [] + + if usage_percentage > 95: + recommendations.extend([ + "Critical: Storage almost full", + "Remove least played tracks immediately", + "Consider upgrading to higher capacity device" + ]) + elif usage_percentage > 90: + recommendations.extend([ + "Storage nearly full", + "Enable auto-cleanup settings", + "Remove old or rarely played tracks" + ]) + elif usage_percentage > 80: + recommendations.extend([ + "Storage getting full", + "Consider using space saver quality", + "Review offline library regularly" + ]) + elif usage_percentage > 70: + recommendations.extend([ + "Moderate storage usage", + "Monitor storage regularly", + "Consider quality adjustments" + ]) + else: + recommendations.extend([ + "Storage usage is healthy", + "Continue current settings", + "Consider adding more content if desired" + ]) + + return recommendations diff --git a/src/swingmusic/api/music_catalog.py b/src/swingmusic/api/music_catalog.py new file mode 100644 index 00000000..aa2e8b4b --- /dev/null +++ b/src/swingmusic/api/music_catalog.py @@ -0,0 +1,467 @@ +""" +Music Catalog API for SwingMusic +Provides Spotify-like browsing of global music catalog with download capabilities +""" + +from flask import Blueprint, request, jsonify +from typing import Dict, List, Any, Optional +import asyncio + +from swingmusic.services.music_catalog import music_catalog_service +from swingmusic import logger +from swingmusic.db.spotify import UserCatalogPreferencesTable + +# Create blueprint +music_catalog_bp = Blueprint('music_catalog', __name__, url_prefix='/api/catalog') + + +@music_catalog_bp.route('/artist//top-tracks', methods=['GET']) +def get_artist_top_tracks(artist_id: str): + """ + Get artist's most popular tracks + + Query parameters: + - limit: Maximum number of tracks (default: 15, max: 50) + - user_id: User ID for preferences + """ + try: + limit = min(request.args.get('limit', 15, type=int), 50) + user_id = request.args.get('user_id', type=int) + + # Get user preferences if available + if user_id: + user_prefs = UserCatalogPreferencesTable.get_or_create(user_id) + limit = min(limit, user_prefs.max_top_tracks) + + # Run async operation + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + tracks = loop.run_until_complete( + music_catalog_service.get_artist_top_tracks(artist_id, limit) + ) + + return jsonify({ + 'tracks': [track.__dict__ for track in tracks], + 'total': len(tracks) + }) + + except Exception as e: + logger.error(f"Error getting artist top tracks: {e}") + return jsonify({'error': 'Failed to get artist top tracks'}), 500 + + +@music_catalog_bp.route('/artist//albums', methods=['GET']) +def get_artist_discography(artist_id: str): + """ + Get complete artist discography with albums + + Query parameters: + - limit: Maximum number of albums (default: 20, max: 50) + - user_id: User ID for preferences + """ + try: + limit = min(request.args.get('limit', 20, type=int), 50) + user_id = request.args.get('user_id', type=int) + + # Get user preferences if available + if user_id: + user_prefs = UserCatalogPreferencesTable.get_or_create(user_id) + limit = min(limit, user_prefs.max_albums_per_artist) + + # Run async operation + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + albums = loop.run_until_complete( + music_catalog_service.get_artist_discography(artist_id) + ) + + # Apply limit + albums = albums[:limit] + + return jsonify({ + 'albums': [album.__dict__ for album in albums], + 'total': len(albums) + }) + + except Exception as e: + logger.error(f"Error getting artist discography: {e}") + return jsonify({'error': 'Failed to get artist discography'}), 500 + + +@music_catalog_bp.route('/artist/', methods=['GET']) +def get_artist_info(artist_id: str): + """ + Get comprehensive artist information including top tracks and albums + + Query parameters: + - user_id: User ID for preferences + """ + try: + user_id = request.args.get('user_id', type=int) + + # Run async operation + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + artist_info = loop.run_until_complete( + music_catalog_service.get_artist_info(artist_id) + ) + + if not artist_info: + return jsonify({'error': 'Artist not found'}), 404 + + return jsonify({ + 'spotify_id': artist_info.spotify_id, + 'name': artist_info.name, + 'image_url': artist_info.image_url, + 'followers': artist_info.followers, + 'popularity': artist_info.popularity, + 'genres': artist_info.genres or [], + 'top_tracks': [track.__dict__ for track in (artist_info.top_tracks or [])], + 'albums': [album.__dict__ for album in (artist_info.albums or [])], + 'related_artists': artist_info.related_artists or [] + }) + + except Exception as e: + logger.error(f"Error getting artist info: {e}") + return jsonify({'error': 'Failed to get artist info'}), 500 + + +@music_catalog_bp.route('/album/', methods=['GET']) +def get_album_details(album_id: str): + """ + Get full album information with tracklist + + Query parameters: + - user_id: User ID for preferences + """ + try: + user_id = request.args.get('user_id', type=int) + + # Run async operation + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + album = loop.run_until_complete( + music_catalog_service.get_album_details(album_id) + ) + + if not album: + return jsonify({'error': 'Album not found'}), 404 + + return jsonify(album.__dict__) + + except Exception as e: + logger.error(f"Error getting album details: {e}") + return jsonify({'error': 'Failed to get album details'}), 500 + + +@music_catalog_bp.route('/search', methods=['POST']) +def search_catalog(): + """ + Search across global music catalog + + Request body: + { + "query": "search query", + "type": "all|tracks|albums|artists|playlists", + "limit": 20, + "user_id": 1 + } + """ + try: + data = request.get_json() + if not data or not data.get('query'): + return jsonify({'error': 'Search query is required'}), 400 + + query = data['query'].strip() + search_type = data.get('type', 'all') + limit = min(data.get('limit', 20), 50) # Cap at 50 + user_id = data.get('user_id') + + # Validate search type + valid_types = ['all', 'tracks', 'albums', 'artists', 'playlists'] + if search_type not in valid_types: + return jsonify({'error': f'Invalid search type. Must be one of: {valid_types}'}), 400 + + # Get user preferences if available + if user_id: + user_prefs = UserCatalogPreferencesTable.get_or_create(user_id) + limit = min(limit, user_prefs.max_search_results) + + # Run async search + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + result = loop.run_until_complete( + music_catalog_service.search_global_catalog(query, search_type, limit) + ) + + return jsonify({ + 'tracks': [track.__dict__ for track in result.tracks], + 'albums': [album.__dict__ for album in result.albums], + 'artists': [artist.__dict__ for artist in result.artists], + 'playlists': [playlist.__dict__ for playlist in result.playlists], + 'total': result.total, + 'query': result.query + }) + + except Exception as e: + logger.error(f"Error searching catalog: {e}") + return jsonify({'error': 'Failed to search catalog'}), 500 + + +@music_catalog_bp.route('/trending', methods=['GET']) +def get_trending_content(): + """ + Get trending/popular content from global catalog + + Query parameters: + - type: "tracks|albums|artists" (default: "tracks") + - limit: Maximum results (default: 20, max: 50) + - user_id: User ID for preferences + """ + try: + content_type = request.args.get('type', 'tracks') + limit = min(request.args.get('limit', 20, type=int), 50) + user_id = request.args.get('user_id', type=int) + + # Validate content type + valid_types = ['tracks', 'albums', 'artists'] + if content_type not in valid_types: + return jsonify({'error': f'Invalid type. Must be one of: {valid_types}'}), 400 + + # Get user preferences if available + if user_id: + user_prefs = UserCatalogPreferencesTable.get_or_create(user_id) + limit = min(limit, user_prefs.max_trending_results) + + # For now, search for popular content with generic queries + # This could be enhanced with actual trending data from Spotify API + trending_queries = { + 'tracks': 'popular hits 2024', + 'albums': 'new releases 2024', + 'artists': 'popular artists' + } + + query = trending_queries.get(content_type, 'popular') + + # Run async search + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + result = loop.run_until_complete( + music_catalog_service.search_global_catalog(query, content_type, limit) + ) + + # Return only the requested type + response = { + 'type': content_type, + 'total': len(getattr(result, content_type)), + 'query': query + } + + if content_type == 'tracks': + response['tracks'] = [track.__dict__ for track in result.tracks] + elif content_type == 'albums': + response['albums'] = [album.__dict__ for album in result.albums] + elif content_type == 'artists': + response['artists'] = [artist.__dict__ for artist in result.artists] + + return jsonify(response) + + except Exception as e: + logger.error(f"Error getting trending content: {e}") + return jsonify({'error': 'Failed to get trending content'}), 500 + + +@music_catalog_bp.route('/recommendations', methods=['POST']) +def get_recommendations(): + """ + Get personalized recommendations based on seeds + + Request body: + { + "seed_artists": ["artist_id1", "artist_id2"], + "seed_tracks": ["track_id1", "track_id2"], + "seed_genres": ["rock", "pop"], + "limit": 20, + "user_id": 1 + } + """ + try: + data = request.get_json() + if not data: + return jsonify({'error': 'Request body is required'}), 400 + + seed_artists = data.get('seed_artists', []) + seed_tracks = data.get('seed_tracks', []) + seed_genres = data.get('seed_genres', []) + limit = min(data.get('limit', 20), 50) + user_id = data.get('user_id') + + # Validate at least one seed type + if not any([seed_artists, seed_tracks, seed_genres]): + return jsonify({'error': 'At least one seed type must be provided'}), 400 + + # Get user preferences if available + if user_id: + user_prefs = UserCatalogPreferencesTable.get_or_create(user_id) + limit = min(limit, user_prefs.max_recommendations) + + # For now, generate recommendations based on seed artists + # This could be enhanced with Spotify's recommendations API + recommendations = [] + + if seed_artists: + # Get top tracks from seed artists + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + for artist_id in seed_artists[:5]: # Limit to 5 artists + try: + tracks = loop.run_until_complete( + music_catalog_service.get_artist_top_tracks(artist_id, 5) + ) + recommendations.extend(tracks) + except Exception as e: + logger.warning(f"Failed to get tracks for artist {artist_id}: {e}") + + # Remove duplicates and apply limit + seen_ids = set() + unique_recommendations = [] + + for track in recommendations: + if track.spotify_id not in seen_ids: + seen_ids.add(track.spotify_id) + unique_recommendations.append(track) + + if len(unique_recommendations) >= limit: + break + + return jsonify({ + 'tracks': [track.__dict__ for track in unique_recommendations], + 'total': len(unique_recommendations), + 'seeds': { + 'artists': seed_artists, + 'tracks': seed_tracks, + 'genres': seed_genres + } + }) + + except Exception as e: + logger.error(f"Error getting recommendations: {e}") + return jsonify({'error': 'Failed to get recommendations'}), 500 + + +@music_catalog_bp.route('/preferences/', methods=['GET', 'POST']) +def user_catalog_preferences(user_id: int): + """ + Get or update user's catalog preferences + + GET: Returns user preferences + POST: Updates user preferences + + POST request body: + { + "max_search_results": 50, + "max_top_tracks": 15, + "max_albums_per_artist": 20, + "max_trending_results": 20, + "max_recommendations": 20, + "show_explicit": true, + "preferred_markets": ["US", "GB", "DE"] + } + """ + try: + if request.method == 'GET': + user_prefs = UserCatalogPreferencesTable.get_or_create(user_id) + return jsonify({ + 'user_id': user_id, + 'max_search_results': user_prefs.max_search_results, + 'max_top_tracks': user_prefs.max_top_tracks, + 'max_albums_per_artist': user_prefs.max_albums_per_artist, + 'max_trending_results': user_prefs.max_trending_results, + 'max_recommendations': user_prefs.max_recommendations, + 'show_explicit': user_prefs.show_explicit, + 'preferred_markets': user_prefs.preferred_markets or [] + }) + + else: # POST + data = request.get_json() + if not data: + return jsonify({'error': 'Request body is required'}), 400 + + user_prefs = UserCatalogPreferencesTable.get_or_create(user_id) + + # Update preferences + if 'max_search_results' in data: + user_prefs.max_search_results = min(data['max_search_results'], 100) + if 'max_top_tracks' in data: + user_prefs.max_top_tracks = min(data['max_top_tracks'], 50) + if 'max_albums_per_artist' in data: + user_prefs.max_albums_per_artist = min(data['max_albums_per_artist'], 100) + if 'max_trending_results' in data: + user_prefs.max_trending_results = min(data['max_trending_results'], 100) + if 'max_recommendations' in data: + user_prefs.max_recommendations = min(data['max_recommendations'], 100) + if 'show_explicit' in data: + user_prefs.show_explicit = bool(data['show_explicit']) + if 'preferred_markets' in data: + user_prefs.preferred_markets = data['preferred_markets'] + + user_prefs.save() + + return jsonify({'message': 'Preferences updated successfully'}) + + except Exception as e: + logger.error(f"Error handling catalog preferences: {e}") + return jsonify({'error': 'Failed to handle preferences'}), 500 + + +@music_catalog_bp.route('/cache/cleanup', methods=['POST']) +def cleanup_cache(): + """ + Clean up expired cache entries + This is typically called by a background job + """ + try: + music_catalog_service.cleanup_expired_cache() + return jsonify({'message': 'Cache cleanup completed'}) + + except Exception as e: + logger.error(f"Error cleaning up cache: {e}") + return jsonify({'error': 'Failed to cleanup cache'}), 500 + + +@music_catalog_bp.route('/health', methods=['GET']) +def health_check(): + """ + Health check endpoint for the music catalog service + """ + try: + # Check if the service is accessible + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # Simple test - try to get a popular artist's top tracks + # Using a well-known artist ID for testing + test_result = loop.run_until_complete( + music_catalog_service.get_artist_top_tracks("4q3ewHC7JlriWjjK2XsvrO", 1) # Daft Punk + ) + + return jsonify({ + 'status': 'healthy', + 'service': 'music_catalog', + 'test_query_success': len(test_result) > 0 + }) + + except Exception as e: + logger.error(f"Health check failed: {e}") + return jsonify({ + 'status': 'unhealthy', + 'service': 'music_catalog', + 'error': str(e) + }), 500 diff --git a/src/swingmusic/api/plugins/lyrics.py b/src/swingmusic/api/plugins/lyrics.py index f572426f..ecf67f1e 100644 --- a/src/swingmusic/api/plugins/lyrics.py +++ b/src/swingmusic/api/plugins/lyrics.py @@ -55,6 +55,10 @@ def search_lyrics(body: LyricsSearchBody): if lrc is not None: lyrics = Lyrics_class(lrc) - return {"trackhash": trackhash, "lyrics": lyrics.format_synced_lyrics()}, 200 + if lyrics.is_synced: + formatted_lyrics = lyrics.format_synced_lyrics() + else: + formatted_lyrics = lyrics.format_unsynced_lyrics() + return {"trackhash": trackhash, "lyrics": formatted_lyrics, "synced": lyrics.is_synced}, 200 - return {"trackhash": trackhash, "lyrics": lrc}, 200 + return {"trackhash": trackhash, "lyrics": None, "synced": False}, 200 diff --git a/src/swingmusic/api/recap.py b/src/swingmusic/api/recap.py new file mode 100644 index 00000000..3898b9be --- /dev/null +++ b/src/swingmusic/api/recap.py @@ -0,0 +1,435 @@ +""" +Year-in-Review API Endpoints + +This module provides REST API endpoints for the year-in-review experience, +including recap generation, summary retrieval, and video generation. +""" + +import logging +from datetime import datetime +from typing import Dict, List, Optional, Any +from flask import Blueprint, request, jsonify +from flask_login import login_required, current_user + +from swingmusic.db import db +from swingmusic.services.recap_service import recap_service, RecapTheme +from swingmusic.utils.request import APIError, success_response, error_response + +logger = logging.getLogger(__name__) + +recap_bp = Blueprint('recap', __name__, url_prefix='/api/recap') + + +def get_current_user_id() -> int: + """Get current user ID from Flask-Login""" + return current_user.id if current_user.is_authenticated else None + + +@recap_bp.route('/generate/', methods=['POST']) +@login_required +async def generate_recap(year: int): + """ + Generate year-in-review for a specific year + + Path Parameters: + - year: Year to generate recap for + + Query Parameters: + - force: Force regeneration even if recap exists (default: false) + """ + try: + user_id = get_current_user_id() + force_regeneration = request.args.get('force', 'false').lower() == 'true' + + # Check if recap already exists + if not force_regeneration: + existing_recap = await recap_service.get_recap_summary(user_id, year) + if existing_recap: + return success_response({ + 'message': f'Recap for {year} already exists', + 'recap': existing_recap + }) + + # Generate new recap + recap_data = await recap_service.generate_year_recap(user_id, year) + + return success_response({ + 'message': f'Successfully generated recap for {year}', + 'recap_id': f"{user_id}_{year}", + 'year': recap_data.year, + 'stats': { + 'total_minutes': recap_data.stats.total_minutes, + 'total_tracks': recap_data.stats.total_tracks, + 'total_artists': recap_data.stats.total_artists, + 'unique_tracks': recap_data.stats.unique_tracks, + 'listening_streak': recap_data.stats.listening_streak, + 'personality_type': recap_data.personality.personality_type + } + }) + + except Exception as e: + logger.error(f"Error generating recap: {e}") + return error_response("Internal server error", 500) + + +@recap_bp.route('/summary/', methods=['GET']) +@login_required +async def get_recap_summary(year: int): + """ + Get recap summary for a specific year + + Path Parameters: + - year: Year to get recap summary for + """ + try: + user_id = get_current_user_id() + + recap_summary = await recap_service.get_recap_summary(user_id, year) + + if not recap_summary: + return error_response(f"No recap found for year {year}", 404) + + return success_response({ + 'recap': recap_summary + }) + + except Exception as e: + logger.error(f"Error getting recap summary: {e}") + return error_response("Internal server error", 500) + + +@recap_bp.route('/details/', methods=['GET']) +@login_required +async def get_recap_details(year: int): + """ + Get detailed recap data for a specific year + + Path Parameters: + - year: Year to get recap details for + + Query Parameters: + - include_top_tracks: Include top tracks data (default: true) + - include_top_artists: Include top artists data (default: true) + - include_top_albums: Include top albums data (default: true) + - include_discoveries: Include discoveries data (default: true) + - include_milestones: Include milestones data (default: true) + """ + try: + user_id = get_current_user_id() + + # Get recap summary first + recap_summary = await recap_service.get_recap_summary(user_id, year) + + if not recap_summary: + return error_response(f"No recap found for year {year}", 404) + + # Parse include flags + include_flags = { + 'top_tracks': request.args.get('include_top_tracks', 'true').lower() == 'true', + 'top_artists': request.args.get('include_top_artists', 'true').lower() == 'true', + 'top_albums': request.args.get('include_top_albums', 'true').lower() == 'true', + 'discoveries': request.args.get('include_discoveries', 'true').lower() == 'true', + 'milestones': request.args.get('include_milestones', 'true').lower() == 'true' + } + + # Load full recap data from file + import json + from pathlib import Path + + recap_file = Path(recap_service.recap_dir) / f"recap_{user_id}_{year}.json" + + if not recap_file.exists(): + return error_response(f"Recap data not found for year {year}", 404) + + with open(recap_file, 'r') as f: + full_recap_data = json.load(f) + + # Build response based on include flags + response_data = { + 'year': full_recap_data['year'], + 'stats': full_recap_data['stats'], + 'personality': full_recap_data['personality'], + 'monthly_breakdown': full_recap_data['monthly_breakdown'], + 'created_at': full_recap_data['created_at'] + } + + if include_flags['top_tracks']: + response_data['top_tracks'] = full_recap_data['top_tracks'] + + if include_flags['top_artists']: + response_data['top_artists'] = full_recap_data['top_artists'] + + if include_flags['top_albums']: + response_data['top_albums'] = full_recap_data['top_albums'] + + if include_flags['discoveries']: + response_data['discoveries'] = full_recap_data['discoveries'] + + if include_flags['milestones']: + response_data['milestones'] = full_recap_data['milestones'] + + return success_response({ + 'recap': response_data + }) + + except Exception as e: + logger.error(f"Error getting recap details: {e}") + return error_response("Internal server error", 500) + + +@recap_bp.route('/video/', methods=['POST']) +@login_required +async def generate_recap_video(year: int): + """ + Generate recap video for a specific year + + Path Parameters: + - year: Year to generate video for + + Request Body: + { + "theme": "modern|retro|minimal|vibrant|dark|light", + "include_audio": true, + "duration_limit": 180 // Optional: max duration in seconds + } + """ + try: + user_id = get_current_user_id() + + # Get request data + data = request.get_json() or {} + + # Validate theme + theme_name = data.get('theme', 'modern') + try: + theme = RecapTheme(theme_name) + except ValueError: + return error_response(f"Invalid theme: {theme_name}. Must be one of: {[t.value for t in RecapTheme]}", 400) + + # Check if recap exists + recap_summary = await recap_service.get_recap_summary(user_id, year) + if not recap_summary: + return error_response(f"No recap found for year {year}. Generate recap first.", 404) + + # Generate video (this is a placeholder - would integrate with Remotion service) + video_path = await recap_service.generate_recap_video( + # This would need to load the full recap data + None, # recap_data would be loaded here + theme + ) + + return success_response({ + 'message': f'Video generation started for {year}', + 'video_path': video_path, + 'theme': theme.value, + 'estimated_completion': '2-5 minutes' + }) + + except Exception as e: + logger.error(f"Error generating recap video: {e}") + return error_response("Internal server error", 500) + + +@recap_bp.route('/available-years', methods=['GET']) +@login_required +async def get_available_years(): + """ + Get list of years for which recaps are available + """ + try: + user_id = get_current_user_id() + + # Scan recap directory for user's recaps + import os + from pathlib import Path + + recap_dir = Path(recap_service.recap_dir) + available_years = [] + + if recap_dir.exists(): + for file_path in recap_dir.glob(f"recap_{user_id}_*.json"): + # Extract year from filename + parts = file_path.stem.split('_') + if len(parts) >= 3: + year = parts[2] + try: + year_int = int(year) + available_years.append(year_int) + except ValueError: + continue + + # Sort years in descending order + available_years.sort(reverse=True) + + return success_response({ + 'available_years': available_years, + 'total_recaps': len(available_years) + }) + + except Exception as e: + logger.error(f"Error getting available years: {e}") + return error_response("Internal server error", 500) + + +@recap_bp.route('/share/', methods=['POST']) +@login_required +async def create_shareable_link(year: int): + """ + Create a shareable link for recap + + Path Parameters: + - year: Year to create shareable link for + + Request Body: + { + "include_personal_data": false, + "expires_in_days": 30 + } + """ + try: + user_id = get_current_user_id() + + # Get request data + data = request.get_json() or {} + include_personal_data = data.get('include_personal_data', False) + expires_in_days = data.get('expires_in_days', 30) + + # Check if recap exists + recap_summary = await recap_service.get_recap_summary(user_id, year) + if not recap_summary: + return error_response(f"No recap found for year {year}", 404) + + # Generate shareable link (this is a placeholder implementation) + import secrets + import hashlib + + # Generate unique token + token_data = f"{user_id}_{year}_{datetime.utcnow().timestamp()}" + share_token = hashlib.sha256(token_data.encode()).hexdigest()[:16] + + # Create shareable data + shareable_data = { + 'year': year, + 'stats': { + 'total_minutes': recap_summary['total_minutes'], + 'total_tracks': recap_summary['total_tracks'], + 'personality_type': recap_summary['personality_type'] + }, + 'top_track': recap_summary.get('top_track'), + 'top_artist': recap_summary.get('top_artist'), + 'created_at': recap_summary['created_at'] + } + + # Save shareable data (in a real implementation, this would go to database) + share_file = Path(recap_service.recap_dir) / f"share_{share_token}.json" + import json + with open(share_file, 'w') as f: + json.dump({ + 'user_id': user_id, + 'year': year, + 'data': shareable_data, + 'expires_at': (datetime.utcnow() + datetime.timedelta(days=expires_in_days)).isoformat(), + 'created_at': datetime.utcnow().isoformat() + }, f) + + share_url = f"/recap/shared/{share_token}" + + return success_response({ + 'share_url': share_url, + 'share_token': share_token, + 'expires_in_days': expires_in_days, + 'includes_personal_data': include_personal_data + }) + + except Exception as e: + logger.error(f"Error creating shareable link: {e}") + return error_response("Internal server error", 500) + + +@recap_bp.route('/shared/', methods=['GET']) +async def get_shared_recap(token: str): + """ + Get shared recap by token (public endpoint) + + Path Parameters: + - token: Share token + """ + try: + # Load share data + share_file = Path(recap_service.recap_dir) / f"share_{token}.json" + + if not share_file.exists(): + return error_response("Shared recap not found or expired", 404) + + import json + with open(share_file, 'r') as f: + share_data = json.load(f) + + # Check if expired + expires_at = datetime.fromisoformat(share_data['expires_at']) + if datetime.utcnow() > expires_at: + share_file.unlink() # Clean up expired share + return error_response("Shared recap has expired", 410) + + return success_response({ + 'shared_recap': share_data['data'], + 'year': share_data['year'], + 'created_at': share_data['created_at'] + }) + + except Exception as e: + logger.error(f"Error getting shared recap: {e}") + return error_response("Internal server error", 500) + + +@recap_bp.route('/compare//', methods=['GET']) +@login_required +async def compare_years(year1: int, year2: int): + """ + Compare recaps between two years + + Path Parameters: + - year1: First year to compare + - year2: Second year to compare + """ + try: + user_id = get_current_user_id() + + # Get both recaps + recap1 = await recap_service.get_recap_summary(user_id, year1) + recap2 = await recap_service.get_recap_summary(user_id, year2) + + if not recap1: + return error_response(f"No recap found for year {year1}", 404) + + if not recap2: + return error_response(f"No recap found for year {year2}", 404) + + # Calculate comparisons + comparison = { + 'year1': year1, + 'year2': year2, + 'listening_time_change': { + 'absolute': recap2['total_minutes'] - recap1['total_minutes'], + 'percentage': ((recap2['total_minutes'] - recap1['total_minutes']) / recap1['total_minutes'] * 100) if recap1['total_minutes'] > 0 else 0 + }, + 'tracks_change': { + 'absolute': recap2['total_tracks'] - recap1['total_tracks'], + 'percentage': ((recap2['total_tracks'] - recap1['total_tracks']) / recap1['total_tracks'] * 100) if recap1['total_tracks'] > 0 else 0 + }, + 'personality_change': { + 'from': recap1['personality_type'], + 'to': recap2['personality_type'], + 'changed': recap1['personality_type'] != recap2['personality_type'] + } + } + + return success_response({ + 'comparison': comparison, + 'recap1': recap1, + 'recap2': recap2 + }) + + except Exception as e: + logger.error(f"Error comparing years: {e}") + return error_response("Internal server error", 500) diff --git a/src/swingmusic/api/spotify.py b/src/swingmusic/api/spotify.py new file mode 100644 index 00000000..1d8e2bd1 --- /dev/null +++ b/src/swingmusic/api/spotify.py @@ -0,0 +1,426 @@ +""" +Spotify Downloader API endpoints for SwingMusic +Provides REST API for Spotify URL downloading functionality +""" + +from flask import Blueprint, request, jsonify +from flask_openapi3 import APIBlueprint, Tag +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +import asyncio + +from swingmusic.services.spotify_downloader import spotify_downloader, DownloadSource +from swingmusic import logger +from swingmusic.utils import create_valid_filename + +spotify_bp = APIBlueprint( + 'spotify', + __name__, + url_prefix='/api/spotify', + abp_tag=Tag(name='Spotify', description='Spotify downloader operations') +) + + +class SpotifyURLRequest(BaseModel): + url: str = Field(..., description='Spotify URL (track, album, or playlist)') + quality: Optional[str] = Field('flac', description='Audio quality (flac, mp3_320, mp3_128)') + output_dir: Optional[str] = Field(None, description='Output directory (optional)') + + +class SpotifyMetadataResponse(BaseModel): + spotify_id: str + title: str + artist: str + album: str + duration_ms: int + image_url: str + release_date: str + track_number: int + total_tracks: int + is_explicit: bool + preview_url: Optional[str] + + +class DownloadItemResponse(BaseModel): + id: str + spotify_url: str + spotify_id: str + title: str + artist: str + album: str + duration_ms: int + image_url: str + quality: str + source: str + status: str + progress: int + file_path: Optional[str] + error_message: Optional[str] + created_at: float + started_at: Optional[float] + completed_at: Optional[float] + + +class QueueStatusResponse(BaseModel): + queue_length: int + active_downloads: int + pending_items: int + queue: List[DownloadItemResponse] + active: List[DownloadItemResponse] + history: List[DownloadItemResponse] + + +class ActionResponse(BaseModel): + success: bool + message: str + item_id: Optional[str] = None + + +@spotify_bp.post('/metadata', summary='Get Spotify metadata') +async def get_metadata(body: SpotifyURLRequest): + """ + Extract metadata from a Spotify URL without downloading + + - **url**: Spotify URL for track, album, or playlist + - **quality**: Preferred audio quality (optional) + + Returns metadata for the Spotify content. + """ + try: + metadata = await spotify_downloader.get_metadata(body.url) + + if not metadata: + return jsonify({ + 'error': 'Invalid Spotify URL or failed to fetch metadata', + 'success': False + }), 400 + + return jsonify({ + 'success': True, + 'metadata': { + 'spotify_id': metadata.spotify_id, + 'title': metadata.title, + 'artist': metadata.artist, + 'album': metadata.album, + 'duration_ms': metadata.duration_ms, + 'image_url': metadata.image_url, + 'release_date': metadata.release_date, + 'track_number': metadata.track_number, + 'total_tracks': metadata.total_tracks, + 'is_explicit': metadata.is_explicit, + 'preview_url': metadata.preview_url + } + }) + + except Exception as e: + logger.error(f"Error getting Spotify metadata: {e}") + return jsonify({ + 'error': str(e), + 'success': False + }), 500 + + +@spotify_bp.post('/download', summary='Download from Spotify URL') +async def download_from_url(body: SpotifyURLRequest): + """ + Add a Spotify URL to the download queue + + - **url**: Spotify URL for track, album, or playlist + - **quality**: Audio quality preference (flac, mp3_320, mp3_128) + - **output_dir**: Custom output directory (optional) + + Adds the item to the download queue and returns the download ID. + """ + try: + # Validate quality + valid_qualities = ['flac', 'mp3_320', 'mp3_128'] + if body.quality not in valid_qualities: + return jsonify({ + 'error': f'Invalid quality. Must be one of: {", ".join(valid_qualities)}', + 'success': False + }), 400 + + # Add to download queue + item_id = spotify_downloader.add_download( + spotify_url=body.url, + output_dir=body.output_dir, + quality=body.quality + ) + + if not item_id: + return jsonify({ + 'error': 'Failed to add download. Invalid URL or duplicate.', + 'success': False + }), 400 + + return jsonify({ + 'success': True, + 'message': 'Download added to queue', + 'item_id': item_id + }) + + except Exception as e: + logger.error(f"Error adding download: {e}") + return jsonify({ + 'error': str(e), + 'success': False + }), 500 + + +@spotify_bp.get('/queue', summary='Get download queue status') +def get_queue_status(): + """ + Get current status of the download queue + + Returns information about queued items, active downloads, and history. + """ + try: + status = spotify_downloader.get_queue_status() + return jsonify({ + 'success': True, + 'data': status + }) + + except Exception as e: + logger.error(f"Error getting queue status: {e}") + return jsonify({ + 'error': str(e), + 'success': False + }), 500 + + +@spotify_bp.post('/cancel/', summary='Cancel download') +def cancel_download(item_id: str): + """ + Cancel a pending or active download + + - **item_id**: ID of the download item to cancel + + Returns success status of the cancellation. + """ + try: + success = spotify_downloader.cancel_download(item_id) + + if success: + return jsonify({ + 'success': True, + 'message': 'Download cancelled successfully' + }) + else: + return jsonify({ + 'success': False, + 'message': 'Download not found or cannot be cancelled' + }), 404 + + except Exception as e: + logger.error(f"Error cancelling download: {e}") + return jsonify({ + 'error': str(e), + 'success': False + }), 500 + + +@spotify_bp.post('/retry/', summary='Retry failed download') +def retry_download(item_id: str): + """ + Retry a failed download + + - **item_id**: ID of the failed download item to retry + + Returns success status of the retry operation. + """ + try: + success = spotify_downloader.retry_download(item_id) + + if success: + return jsonify({ + 'success': True, + 'message': 'Download added to queue for retry' + }) + else: + return jsonify({ + 'success': False, + 'message': 'Download not found or cannot be retried' + }), 404 + + except Exception as e: + logger.error(f"Error retrying download: {e}") + return jsonify({ + 'error': str(e), + 'success': False + }), 500 + + +@spotify_bp.get('/sources', summary='Get available download sources') +def get_download_sources(): + """ + Get list of available download sources and their status + + Returns information about available download sources (Tidal, Qobuz, Amazon). + """ + try: + sources = [] + for source in DownloadSource: + sources.append({ + 'name': source.value, + 'display_name': source.value.title(), + 'enabled': True, # In real implementation, check availability + 'priority': list(DownloadSource).index(source) + }) + + return jsonify({ + 'success': True, + 'sources': sources + }) + + except Exception as e: + logger.error(f"Error getting download sources: {e}") + return jsonify({ + 'error': str(e), + 'success': False + }), 500 + + +@spotify_bp.get('/qualities', summary='Get available audio qualities') +def get_audio_qualities(): + """ + Get list of available audio qualities + + Returns supported audio formats and quality options. + """ + try: + qualities = [ + { + 'id': 'flac', + 'name': 'FLAC', + 'description': 'Lossless audio quality', + 'extension': 'flac', + 'bitrate': 'Lossless' + }, + { + 'id': 'mp3_320', + 'name': 'MP3 320kbps', + 'description': 'High quality MP3', + 'extension': 'mp3', + 'bitrate': '320 kbps' + }, + { + 'id': 'mp3_128', + 'name': 'MP3 128kbps', + 'description': 'Standard quality MP3', + 'extension': 'mp3', + 'bitrate': '128 kbps' + } + ] + + return jsonify({ + 'success': True, + 'qualities': qualities + }) + + except Exception as e: + logger.error(f"Error getting audio qualities: {e}") + return jsonify({ + 'error': str(e), + 'success': False + }), 500 + + +@spotify_bp.get('/history', summary='Get download history') +def get_download_history(): + """ + Get download history + + Returns paginated download history. + """ + try: + # Get query parameters + page = int(request.args.get('page', 1)) + limit = int(request.args.get('limit', 50)) + status_filter = request.args.get('status', None) + + # Get history from downloader + status = spotify_downloader.get_queue_status() + history = status.get('history', []) + + # Apply status filter + if status_filter: + history = [item for item in history if item.get('status') == status_filter] + + # Paginate + total = len(history) + start = (page - 1) * limit + end = start + limit + paginated_history = history[start:end] + + return jsonify({ + 'success': True, + 'data': { + 'items': paginated_history, + 'pagination': { + 'page': page, + 'limit': limit, + 'total': total, + 'pages': (total + limit - 1) // limit + } + } + }) + + except Exception as e: + logger.error(f"Error getting download history: {e}") + return jsonify({ + 'error': str(e), + 'success': False + }), 500 + + +@spotify_bp.delete('/clear-history', summary='Clear download history') +def clear_download_history(): + """ + Clear download history + + Removes all completed and failed downloads from history. + """ + try: + # Clear history in downloader + spotify_downloader.download_history.clear() + + return jsonify({ + 'success': True, + 'message': 'Download history cleared' + }) + + except Exception as e: + logger.error(f"Error clearing download history: {e}") + return jsonify({ + 'error': str(e), + 'success': False + }), 500 + + +# Error handlers +@spotify_bp.errorhandler(400) +def bad_request(error): + return jsonify({ + 'error': 'Bad request', + 'message': str(error), + 'success': False + }), 400 + + +@spotify_bp.errorhandler(404) +def not_found(error): + return jsonify({ + 'error': 'Not found', + 'message': str(error), + 'success': False + }), 404 + + +@spotify_bp.errorhandler(500) +def internal_error(error): + return jsonify({ + 'error': 'Internal server error', + 'message': str(error), + 'success': False + }), 500 diff --git a/src/swingmusic/api/spotify_settings.py b/src/swingmusic/api/spotify_settings.py new file mode 100644 index 00000000..02ef03e4 --- /dev/null +++ b/src/swingmusic/api/spotify_settings.py @@ -0,0 +1,372 @@ +""" +Spotify Downloader Settings API endpoints +""" + +from flask import Blueprint, request, jsonify +from flask_openapi3 import APIBlueprint, Tag +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any + +from swingmusic import logger +from swingmusic.config import UserConfig + +spotify_settings_bp = APIBlueprint( + 'spotify_settings', + __name__, + url_prefix='/api/settings/spotify', + abp_tag=Tag(name='Spotify Settings', description='Spotify downloader settings operations') +) + + +class SpotifySettingsRequest(BaseModel): + defaultQuality: str = Field('flac', description='Default download quality') + downloadFolder: Optional[str] = Field(None, description='Download folder path') + autoAddToLibrary: bool = Field(True, description='Auto-add downloads to library') + maxConcurrentDownloads: int = Field(3, description='Max concurrent downloads') + sources: Optional[list] = Field(None, description='Download sources configuration') + maxRetryAttempts: int = Field(3, description='Max retry attempts') + cleanupHistoryDays: int = Field(30, description='Auto-cleanup history days') + showExplicitWarning: bool = Field(True, description='Show explicit content warning') + + +class SpotifySettingsResponse(BaseModel): + success: bool + settings: Optional[Dict[str, Any]] = None + message: Optional[str] = None + + +# Default settings +DEFAULT_SETTINGS = { + 'defaultQuality': 'flac', + 'downloadFolder': '', + 'autoAddToLibrary': True, + 'maxConcurrentDownloads': 3, + 'sources': [ + { + 'name': 'tidal', + 'display_name': 'Tidal', + 'enabled': True, + 'priority': 1, + 'config': { + 'quality_preference': ['lossless', 'high', 'normal'], + 'formats': ['flac', 'mp3'] + } + }, + { + 'name': 'qobuz', + 'display_name': 'Qobuz', + 'enabled': True, + 'priority': 2, + 'config': { + 'quality_preference': ['lossless', 'high', 'normal'], + 'formats': ['flac', 'mp3'] + } + }, + { + 'name': 'amazon', + 'display_name': 'Amazon Music', + 'enabled': False, + 'priority': 3, + 'config': { + 'quality_preference': ['high', 'normal'], + 'formats': ['mp3', 'aac'] + } + } + ], + 'maxRetryAttempts': 3, + 'cleanupHistoryDays': 30, + 'showExplicitWarning': True +} + + +def get_spotify_settings(): + """Get Spotify downloader settings from config""" + try: + config = UserConfig() + spotify_settings = config.spotify_downloads if hasattr(config, 'spotify_downloads') else {} + + # Merge with defaults + settings = {**DEFAULT_SETTINGS} + settings.update(spotify_settings) + + return settings + except Exception as e: + logger.error(f"Error loading Spotify settings: {e}") + return DEFAULT_SETTINGS + + +def save_spotify_settings(settings_data: dict): + """Save Spotify downloader settings to config""" + try: + config = UserConfig() + + # Update only provided settings + current_settings = get_spotify_settings() + current_settings.update(settings_data) + + # Save to config + config.spotify_downloads = current_settings + config.save() + + logger.info("Spotify settings saved successfully") + return True + except Exception as e: + logger.error(f"Error saving Spotify settings: {e}") + return False + + +@spotify_settings_bp.get('/', summary='Get Spotify downloader settings') +def get_settings(): + """ + Get current Spotify downloader settings + + Returns all Spotify downloader configuration including: + - Default quality settings + - Download folder configuration + - Source priorities and enablement + - Advanced options + """ + try: + settings = get_spotify_settings() + + return jsonify({ + 'success': True, + 'settings': settings + }) + + except Exception as e: + logger.error(f"Error getting Spotify settings: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +@spotify_settings_bp.post('/', summary='Update Spotify downloader settings') +def update_settings(body: SpotifySettingsRequest): + """ + Update Spotify downloader settings + + - **defaultQuality**: Default download quality (flac, mp3_320, mp3_128) + - **downloadFolder**: Custom download folder path + - **autoAddToLibrary**: Whether to auto-add downloads to library + - **maxConcurrentDownloads**: Maximum concurrent downloads (1-10) + - **sources**: Download sources configuration + - **maxRetryAttempts**: Maximum retry attempts for failed downloads + - **cleanupHistoryDays**: Days to keep download history (0 = disabled) + - **showExplicitWarning**: Show warning for explicit content + + Updates the Spotify downloader configuration and saves to user settings. + """ + try: + # Validate inputs + if body.defaultQuality not in ['flac', 'mp3_320', 'mp3_128']: + return jsonify({ + 'success': False, + 'message': 'Invalid quality setting' + }), 400 + + if not 1 <= body.maxConcurrentDownloads <= 10: + return jsonify({ + 'success': False, + 'message': 'Max concurrent downloads must be between 1 and 10' + }), 400 + + if not 0 <= body.maxRetryAttempts <= 10: + return jsonify({ + 'success': False, + 'message': 'Max retry attempts must be between 0 and 10' + }), 400 + + if not 0 <= body.cleanupHistoryDays <= 365: + return jsonify({ + 'success': False, + 'message': 'Cleanup days must be between 0 and 365' + }), 400 + + # Prepare settings data + settings_data = { + 'defaultQuality': body.defaultQuality, + 'downloadFolder': body.downloadFolder, + 'autoAddToLibrary': body.autoAddToLibrary, + 'maxConcurrentDownloads': body.maxConcurrentDownloads, + 'sources': body.sources, + 'maxRetryAttempts': body.maxRetryAttempts, + 'cleanupHistoryDays': body.cleanupHistoryDays, + 'showExplicitWarning': body.showExplicitWarning + } + + # Remove None values + settings_data = {k: v for k, v in settings_data.items() if v is not None} + + # Save settings + if save_spotify_settings(settings_data): + return jsonify({ + 'success': True, + 'message': 'Settings saved successfully' + }) + else: + return jsonify({ + 'success': False, + 'message': 'Failed to save settings' + }), 500 + + except Exception as e: + logger.error(f"Error updating Spotify settings: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +@spotify_settings_bp.post('/reset', summary='Reset Spotify settings to defaults') +def reset_settings(): + """ + Reset all Spotify downloader settings to default values + + Resets all Spotify downloader configuration to factory defaults. + """ + try: + if save_spotify_settings(DEFAULT_SETTINGS): + return jsonify({ + 'success': True, + 'message': 'Settings reset to defaults', + 'settings': DEFAULT_SETTINGS + }) + else: + return jsonify({ + 'success': False, + 'message': 'Failed to reset settings' + }), 500 + + except Exception as e: + logger.error(f"Error resetting Spotify settings: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +@spotify_settings_bp.delete('/queue', summary='Clear download queue') +def clear_queue(): + """ + Clear the entire download queue + + Removes all pending and active downloads from the queue. + """ + try: + from swingmusic.services.spotify_downloader import spotify_downloader + + # Clear queue + spotify_downloader.download_queue.clear() + + return jsonify({ + 'success': True, + 'message': 'Download queue cleared' + }) + + except Exception as e: + logger.error(f"Error clearing download queue: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +@spotify_settings_bp.delete('/history', summary='Clear download history') +def clear_history(): + """ + Clear the download history + + Removes all completed and failed downloads from history. + """ + try: + from swingmusic.services.spotify_downloader import spotify_downloader + + # Clear history + spotify_downloader.download_history.clear() + + return jsonify({ + 'success': True, + 'message': 'Download history cleared' + }) + + except Exception as e: + logger.error(f"Error clearing download history: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +@spotify_settings_bp.get('/sources', summary='Get available download sources') +def get_available_sources(): + """ + Get list of available download sources + + Returns information about supported download sources and their capabilities. + """ + try: + sources = [ + { + 'name': 'tidal', + 'display_name': 'Tidal', + 'description': 'High-quality FLAC downloads from Tidal', + 'quality_options': ['lossless', 'high', 'normal'], + 'formats': ['flac', 'mp3'], + 'available': True, + 'requires_auth': False, + 'max_quality': 'lossless' + }, + { + 'name': 'qobuz', + 'display_name': 'Qobuz', + 'description': 'Alternative high-quality source with extensive catalog', + 'quality_options': ['lossless', 'high', 'normal'], + 'formats': ['flac', 'mp3'], + 'available': True, + 'requires_auth': True, + 'max_quality': 'lossless' + }, + { + 'name': 'amazon', + 'display_name': 'Amazon Music', + 'description': 'Fallback source with wide availability', + 'quality_options': ['high', 'normal'], + 'formats': ['mp3', 'aac'], + 'available': False, # Disabled by default + 'requires_auth': True, + 'max_quality': 'high' + } + ] + + return jsonify({ + 'success': True, + 'sources': sources + }) + + except Exception as e: + logger.error(f"Error getting available sources: {e}") + return jsonify({ + 'success': False, + 'message': str(e) + }), 500 + + +# Error handlers +@spotify_settings_bp.errorhandler(400) +def bad_request(error): + return jsonify({ + 'error': 'Bad request', + 'message': str(error), + 'success': False + }), 400 + + +@spotify_settings_bp.errorhandler(500) +def internal_error(error): + return jsonify({ + 'error': 'Internal server error', + 'message': str(error), + 'success': False + }), 500 diff --git a/src/swingmusic/api/stream.py b/src/swingmusic/api/stream.py index 63c49714..d865460a 100644 --- a/src/swingmusic/api/stream.py +++ b/src/swingmusic/api/stream.py @@ -1,5 +1,5 @@ """ -Contains all the track routes. +Contains all the track routes with iOS compatibility enhancements. """ import os @@ -18,6 +18,7 @@ from swingmusic.lib.trackslib import get_silence_paddings from swingmusic.store.tracks import TrackStore from swingmusic.utils.files import guess_mime_type +from swingmusic.services.ios_audio_compatibility import ios_audio_manager bp_tag = Tag(name="File", description="Audio files") api = APIBlueprint("track", __name__, url_prefix="/file", abp_tags=[bp_tag]) @@ -54,11 +55,12 @@ class SendTrackFileQuery(BaseModel): @api.get("//legacy") def send_track_file_legacy(path: TrackHashSchema, query: SendTrackFileQuery): """ - Get a playable audio file without Range support + Get a playable audio file without Range support (iOS compatible) Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found. + Automatically handles iOS compatibility by transcoding to supported formats when needed. - NOTE: Does not support range requests or transcoding. + NOTE: Does not support range requests or transcoding beyond iOS compatibility. """ requested_trackhash = path.trackhash.strip() filepath = query.filepath.strip() @@ -106,14 +108,165 @@ def send_track_file_legacy(path: TrackHashSchema, query: SendTrackFileQuery): break if track is not None: - audio_type = guess_mime_type(track.filepath) - return send_from_directory( - Path(track.filepath).parent, - Path(track.filepath).name, + # Detect iOS capabilities and handle compatibility + user_agent = request.headers.get('User-Agent', '') + ios_capabilities = ios_audio_manager.detect_ios_capabilities(user_agent) + + # Create iOS-compatible audio source + audio_source = ios_audio_manager.create_ios_audio_source( + track.filepath, + ios_capabilities, + quality="high" + ) + + # Use the potentially transcoded file path + final_file_path = audio_source['file_path'] + audio_type = audio_source['mime_type'] + + # Add iOS compatibility headers + response = send_from_directory( + Path(final_file_path).parent, + Path(final_file_path).name, mimetype=audio_type, conditional=True, as_attachment=True, ) + + # Add iOS-specific headers + if ios_capabilities.is_ios: + response.headers['Accept-Ranges'] = 'bytes' + response.headers['Cache-Control'] = 'public, max-age=3600' + + # Add transcoding info if applicable + if audio_source['needs_transcoding']: + response.headers['X-iOS-Transcoded'] = 'true' + response.headers['X-iOS-Original-Format'] = guess_mime_type(track.filepath) + response.headers['X-iOS-Target-Format'] = audio_source['format'] + + return response + + return msg, 404 + + +@api.get("//ios") +def send_track_file_ios(path: TrackHashSchema, query: SendTrackFileQuery): + """ + Get a playable audio file optimized for iOS devices + + Returns a playable audio file optimized for iOS compatibility with automatic transcoding. + Supports FLAC to ALAC/AAC conversion and proper MIME types for iOS Safari and other browsers. + + iOS Features: + - Automatic FLAC to ALAC/AAC transcoding + - Proper MP4 container formatting + - iOS-compatible MIME types + - Optimized bitrate for mobile streaming + - Caching for transcoded files + """ + requested_trackhash = path.trackhash.strip() + filepath = query.filepath.strip() + + msg = {"msg": "File Not Found"} + + # prevent path traversal + if "/../" in filepath: + return {"msg": "Invalid filepath", "error": "Path traversal detected"}, 400 + + requested_filepath = Path(filepath).resolve() + + # check if filepath is a child of any of the root dirs + for root_dir in UserConfig().rootDirs: + if root_dir == "$home": + root_dir = Path.home() + else: + root_dir = Path(root_dir).resolve() + + if root_dir not in requested_filepath.parents: + return { + "msg": "Invalid filepath", + "error": "File not inside root directories", + }, 400 + + track = None + tracks = TrackStore.get_tracks_by_filepaths([filepath]) + + if len(tracks) > 0 and os.path.exists(tracks[0].filepath): + for t in tracks: + if os.path.exists(t.filepath) and t.trackhash == requested_trackhash: + track = t + break + else: + group = TrackStore.trackhashmap.get(requested_trackhash) + + # When finding by trackhash, sort by bitrate + # and get the first track that exists + if group is not None: + tracks = sorted(group.tracks, key=lambda x: x.bitrate, reverse=True) + + for t in tracks: + if os.path.exists(t.filepath): + track = t + break + + if track is not None: + # Detect iOS capabilities + user_agent = request.headers.get('User-Agent', '') + ios_capabilities = ios_audio_manager.detect_ios_capabilities(user_agent) + + # Determine quality based on query parameter or device capabilities + quality_map = { + 'original': 'lossless', + '1411': 'lossless', + '1024': 'lossless', + '512': 'high', + '320': 'high', + '256': 'high', + '128': 'medium', + '96': 'low' + } + quality = quality_map.get(query.quality, 'high') + + # Create iOS-optimized audio source + audio_source = ios_audio_manager.create_ios_audio_source( + track.filepath, + ios_capabilities, + quality=quality + ) + + # Use the potentially transcoded file path + final_file_path = audio_source['file_path'] + audio_type = audio_source['mime_type'] + + # Create response with iOS-specific optimizations + response = send_from_directory( + Path(final_file_path).parent, + Path(final_file_path).name, + mimetype=audio_type, + conditional=True, + as_attachment=False, # Stream inline for iOS + ) + + # iOS-specific headers for optimal playback + response.headers['Accept-Ranges'] = 'bytes' + response.headers['Cache-Control'] = 'public, max-age=7200' # 2 hours + response.headers['X-Content-Type-Options'] = 'nosniff' + + # Add iOS compatibility information + if ios_capabilities.is_ios: + response.headers['X-iOS-Optimized'] = 'true' + response.headers['X-iOS-Device'] = 'iPhone' if 'iPhone' in user_agent else 'iPad' if 'iPad' in user_agent else 'iPod' + + # Add transcoding information + if audio_source['needs_transcoding']: + response.headers['X-iOS-Transcoded'] = 'true' + response.headers['X-iOS-Original-Format'] = guess_mime_type(track.filepath) + response.headers['X-iOS-Target-Format'] = audio_source['format'] + response.headers['X-iOS-Quality'] = quality + else: + response.headers['X-iOS-Transcoded'] = 'false' + response.headers['X-iOS-Native-Format'] = 'true' + + return response return msg, 404 diff --git a/src/swingmusic/api/universal_downloader.py b/src/swingmusic/api/universal_downloader.py new file mode 100644 index 00000000..4ebd6407 --- /dev/null +++ b/src/swingmusic/api/universal_downloader.py @@ -0,0 +1,439 @@ +""" +Universal Music Downloader API for SwingMusic +Supports multiple music streaming services for universal downloading +""" + +from flask import Blueprint, request, jsonify +from typing import Dict, List, Any, Optional +import asyncio + +from swingmusic.services.universal_music_downloader import universal_music_downloader, DownloadQuality +from swingmusic.services.universal_url_parser import universal_url_parser, MusicService +from swingmusic import logger + +# Create blueprint +universal_downloader_bp = Blueprint('universal_downloader', __name__, url_prefix='/api/universal') + + +@universal_downloader_bp.route('/download', methods=['POST']) +def add_download(): + """ + Add a download from any supported music service URL + + Request body: + { + "url": "music service URL", + "quality": "lossless|high|medium|low", + "output_dir": "/path/to/output" + } + """ + try: + data = request.get_json() + if not data or not data.get('url'): + return jsonify({'error': 'URL is required'}), 400 + + url = data['url'].strip() + quality_str = data.get('quality', 'high') + output_dir = data.get('output_dir') + + # Validate quality + try: + quality = DownloadQuality(quality_str) + except ValueError: + return jsonify({'error': f'Invalid quality: {quality_str}'}), 400 + + # Parse URL + parsed_url = universal_music_downloader.parse_url(url) + if not parsed_url: + return jsonify({'error': 'Unsupported URL format'}), 400 + + # Add to download queue + item_id = universal_music_downloader.add_download(url, quality, output_dir) + + if item_id: + return jsonify({ + 'success': True, + 'item_id': item_id, + 'service': parsed_url.service.value, + 'item_type': parsed_url.item_type, + 'message': f'Added to download queue from {parsed_url.service.value}' + }) + else: + return jsonify({'error': 'Failed to add download'}), 500 + + except Exception as e: + logger.error(f"Error adding download: {e}") + return jsonify({'error': 'Internal server error'}), 500 + + +@universal_downloader_bp.route('/metadata', methods=['POST']) +def get_metadata(): + """ + Get metadata for any supported music service URL + + Request body: + { + "url": "music service URL" + } + """ + try: + data = request.get_json() + if not data or not data.get('url'): + return jsonify({'error': 'URL is required'}), 400 + + url = data['url'].strip() + + # Parse URL + parsed_url = universal_music_downloader.parse_url(url) + if not parsed_url: + return jsonify({'error': 'Unsupported URL format'}), 400 + + # Get metadata + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + metadata = loop.run_until_complete(universal_music_downloader.get_metadata(url)) + finally: + loop.close() + + if metadata: + return jsonify({ + 'success': True, + 'service': metadata.service.value, + 'service_id': metadata.service_id, + 'item_type': parsed_url.item_type, + 'title': metadata.title, + 'artist': metadata.artist, + 'album': metadata.album, + 'duration_ms': metadata.duration_ms, + 'image_url': metadata.image_url, + 'release_date': metadata.release_date, + 'explicit': metadata.explicit, + 'preview_url': metadata.preview_url, + 'genre': metadata.genre, + 'original_url': metadata.original_url, + 'download_urls': metadata.download_urls + }) + else: + return jsonify({'error': 'Failed to get metadata'}), 404 + + except Exception as e: + logger.error(f"Error getting metadata: {e}") + return jsonify({'error': 'Internal server error'}), 500 + + +@universal_downloader_bp.route('/queue', methods=['GET']) +def get_queue_status(): + """Get current download queue status""" + try: + status = universal_music_downloader.get_queue_status() + return jsonify(status) + except Exception as e: + logger.error(f"Error getting queue status: {e}") + return jsonify({'error': 'Internal server error'}), 500 + + +@universal_downloader_bp.route('/queue//cancel', methods=['POST']) +def cancel_download(item_id: str): + """Cancel a download""" + try: + success = universal_music_downloader.cancel_download(item_id) + if success: + return jsonify({'success': True, 'message': 'Download cancelled'}) + else: + return jsonify({'error': 'Download not found or cannot be cancelled'}), 404 + except Exception as e: + logger.error(f"Error cancelling download: {e}") + return jsonify({'error': 'Internal server error'}), 500 + + +@universal_downloader_bp.route('/queue//retry', methods=['POST']) +def retry_download(item_id: str): + """Retry a failed download""" + try: + success = universal_music_downloader.retry_download(item_id) + if success: + return jsonify({'success': True, 'message': 'Download retry added to queue'}) + else: + return jsonify({'error': 'Download not found or cannot be retried'}), 404 + except Exception as e: + logger.error(f"Error retrying download: {e}") + return jsonify({'error': 'Internal server error'}), 500 + + +@universal_downloader_bp.route('/history', methods=['GET']) +def get_download_history(): + """ + Get download history + + Query parameters: + - limit: number of items (default 100) + - offset: offset for pagination (default 0) + - user_id: user ID for filtering (optional) + """ + try: + limit = min(int(request.args.get('limit', 100)), 500) + offset = int(request.args.get('offset', 0)) + user_id = request.args.get('user_id') + + if user_id: + user_id = int(user_id) + + # Get history from universal downloader + # This would need to be implemented in the service + return jsonify({ + 'downloads': [], + 'total': 0, + 'limit': limit, + 'offset': offset + }) + except ValueError: + return jsonify({'error': 'Invalid parameters'}), 400 + except Exception as e: + logger.error(f"Error getting download history: {e}") + return jsonify({'error': 'Internal server error'}), 500 + + +@universal_downloader_bp.route('/services', methods=['GET']) +def get_supported_services(): + """Get list of supported music services""" + try: + services = universal_music_downloader.get_supported_services() + return jsonify({ + 'services': services, + 'total': len(services) + }) + except Exception as e: + logger.error(f"Error getting supported services: {e}") + return jsonify({'error': 'Internal server error'}), 500 + + +@universal_downloader_bp.route('/services//enable', methods=['POST']) +def enable_service(service_name: str): + """Enable a music service""" + try: + from swingmusic.db.spotify import UniversalDownloadSourceTable + + # Update service in database + UniversalDownloadSourceTable.update_source(service_name, enabled=True) + + return jsonify({ + 'success': True, + 'message': f'{service_name} service enabled' + }) + except Exception as e: + logger.error(f"Error enabling service: {e}") + return jsonify({'error': 'Internal server error'}), 500 + + +@universal_downloader_bp.route('/services//disable', methods=['POST']) +def disable_service(service_name: str): + """Disable a music service""" + try: + from swingmusic.db.spotify import UniversalDownloadSourceTable + + # Update service in database + UniversalDownloadSourceTable.update_source(service_name, enabled=False) + + return jsonify({ + 'success': True, + 'message': f'{service_name} service disabled' + }) + except Exception as e: + logger.error(f"Error disabling service: {e}") + return jsonify({'error': 'Internal server error'}), 500 + + +@universal_downloader_bp.route('/services//config', methods=['GET', 'POST']) +def service_config(service_name: str): + """Get or update service configuration""" + try: + from swingmusic.db.spotify import UniversalDownloadSourceTable + + if request.method == 'GET': + source = UniversalDownloadSourceTable.get_by_service(service_name) + if not source: + return jsonify({'error': 'Service not found'}), 404 + + return jsonify({ + 'service': source.service, + 'display_name': source.display_name, + 'enabled': source.enabled, + 'priority': source.priority, + 'supported_types': source.supported_types, + 'features': source.features, + 'config': source.config + }) + + elif request.method == 'POST': + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + # Update only allowed fields + update_data = {} + allowed_fields = ['enabled', 'priority', 'supported_types', 'features', 'config'] + + for field in allowed_fields: + if field in data: + update_data[field] = data[field] + + if update_data: + UniversalDownloadSourceTable.update_source(service_name, **update_data) + + return jsonify({'success': True, 'message': 'Service configuration updated'}) + + except Exception as e: + logger.error(f"Error handling service config: {e}") + return jsonify({'error': 'Internal server error'}), 500 + + +@universal_downloader_bp.route('/validate-url', methods=['POST']) +def validate_url(): + """ + Validate and parse a music service URL + + Request body: + { + "url": "music service URL" + } + """ + try: + data = request.get_json() + if not data or not data.get('url'): + return jsonify({'error': 'URL is required'}), 400 + + url = data['url'].strip() + + # Parse URL + parsed_url = universal_music_downloader.parse_url(url) + + if parsed_url: + return jsonify({ + 'valid': True, + 'service': parsed_url.service.value, + 'item_type': parsed_url.item_type, + 'id': parsed_url.id, + 'metadata': parsed_url.metadata + }) + else: + return jsonify({ + 'valid': False, + 'error': 'Unsupported URL format' + }) + + except Exception as e: + logger.error(f"Error validating URL: {e}") + return jsonify({'error': 'Internal server error'}), 500 + + +@universal_downloader_bp.route('/statistics', methods=['GET']) +def get_statistics(): + """Get download statistics by service""" + try: + from swingmusic.db.spotify import UniversalDownloadTable + + stats = UniversalDownloadTable.get_statistics() + return jsonify({ + 'statistics': stats, + 'generated_at': logger.info(f"Statistics generated") + }) + except Exception as e: + logger.error(f"Error getting statistics: {e}") + return jsonify({'error': 'Internal server error'}), 500 + + +@universal_downloader_bp.route('/batch', methods=['POST']) +def batch_download(): + """ + Add multiple URLs to download queue + + Request body: + { + "urls": ["url1", "url2", "url3"], + "quality": "high", + "output_dir": "/path/to/output" + } + """ + try: + data = request.get_json() + if not data or not data.get('urls'): + return jsonify({'error': 'URLs array is required'}), 400 + + urls = data['urls'] + quality_str = data.get('quality', 'high') + output_dir = data.get('output_dir') + + if not isinstance(urls, list): + return jsonify({'error': 'URLs must be an array'}), 400 + + # Validate quality + try: + quality = DownloadQuality(quality_str) + except ValueError: + return jsonify({'error': f'Invalid quality: {quality_str}'}), 400 + + # Process each URL + results = [] + for url in urls: + url = url.strip() + if not url: + continue + + try: + # Parse URL + parsed_url = universal_music_downloader.parse_url(url) + if not parsed_url: + results.append({ + 'url': url, + 'success': False, + 'error': 'Unsupported URL format' + }) + continue + + # Add to download queue + item_id = universal_music_downloader.add_download(url, quality, output_dir) + + if item_id: + results.append({ + 'url': url, + 'success': True, + 'item_id': item_id, + 'service': parsed_url.service.value, + 'item_type': parsed_url.item_type + }) + else: + results.append({ + 'url': url, + 'success': False, + 'error': 'Failed to add to queue' + }) + + except Exception as e: + logger.error(f"Error processing URL {url}: {e}") + results.append({ + 'url': url, + 'success': False, + 'error': 'Processing error' + }) + + successful = sum(1 for r in results if r['success']) + failed = len(results) - successful + + return jsonify({ + 'total': len(results), + 'successful': successful, + 'failed': failed, + 'results': results + }) + + except Exception as e: + logger.error(f"Error in batch download: {e}") + return jsonify({'error': 'Internal server error'}), 500 + + +def register_universal_downloader_api(app): + """Register universal downloader API with Flask app""" + app.register_blueprint(universal_downloader_bp) + logger.info("Universal music downloader API registered") diff --git a/src/swingmusic/api/update_tracking.py b/src/swingmusic/api/update_tracking.py new file mode 100644 index 00000000..2dad0c7f --- /dev/null +++ b/src/swingmusic/api/update_tracking.py @@ -0,0 +1,601 @@ +""" +Update Tracking API Endpoints + +This module provides REST API endpoints for the artist update tracking system, +including following artists, managing preferences, and getting updates. +""" + +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any +from flask import Blueprint, request, jsonify +from flask_login import login_required, current_user + +from swingmusic.db import db +from swingmusic.services.update_tracker import update_tracker, FollowLevel, ReleaseType +from swingmusic.utils.request import APIError, success_response, error_response +from swingmusic.utils.validators import validate_spotify_id, validate_email + +logger = logging.getLogger(__name__) + +update_tracking_bp = Blueprint('update_tracking', __name__, url_prefix='/api/updates') + + +def get_current_user_id() -> int: + """Get current user ID from Flask-Login""" + return current_user.id if current_user.is_authenticated else None + + +@update_tracking_bp.route('/follow-artist', methods=['POST']) +@login_required +async def follow_artist(): + """ + Follow an artist for update tracking + + Request Body: + { + "artist_id": "spotify_artist_id", + "artist_name": "Artist Name", + "follow_level": "followed|favorite|casual", + "auto_download": false, + "preferred_quality": "flac" + } + """ + try: + data = request.get_json() + + if not data: + return error_response("Request body is required", 400) + + # Validate required fields + artist_id = data.get('artist_id') + artist_name = data.get('artist_name') + + if not artist_id or not artist_name: + return error_response("artist_id and artist_name are required", 400) + + if not validate_spotify_id(artist_id): + return error_response("Invalid artist ID format", 400) + + # Validate follow level + follow_level = data.get('follow_level', 'followed') + if follow_level not in ['casual', 'followed', 'favorite']: + return error_response("Invalid follow level. Must be: casual, followed, or favorite", 400) + + # Validate quality preference + preferred_quality = data.get('preferred_quality', 'flac') + if preferred_quality not in ['flac', 'mp3_320', 'mp3_256', 'aac']: + return error_response("Invalid quality preference", 400) + + follow_data = { + 'user_id': get_current_user_id(), + 'artist_id': artist_id, + 'artist_name': artist_name, + 'follow_level': follow_level, + 'auto_download': data.get('auto_download', False), + 'preferred_quality': preferred_quality + } + + success = await update_tracker.follow_artist(follow_data) + + if success: + return success_response({ + 'message': f'Now following {artist_name}', + 'artist_id': artist_id, + 'follow_level': follow_level + }) + else: + return error_response("Failed to follow artist", 500) + + except Exception as e: + logger.error(f"Error following artist: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/unfollow-artist', methods=['POST']) +@login_required +async def unfollow_artist(): + """ + Unfollow an artist + + Request Body: + { + "artist_id": "spotify_artist_id" + } + """ + try: + data = request.get_json() + + if not data or not data.get('artist_id'): + return error_response("artist_id is required", 400) + + artist_id = data['artist_id'] + + if not validate_spotify_id(artist_id): + return error_response("Invalid artist ID format", 400) + + success = await update_tracker.unfollow_artist(get_current_user_id(), artist_id) + + if success: + return success_response({ + 'message': 'Artist unfollowed successfully', + 'artist_id': artist_id + }) + else: + return error_response("Failed to unfollow artist", 500) + + except Exception as e: + logger.error(f"Error unfollowing artist: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/recent', methods=['GET']) +@login_required +async def get_recent_updates(): + """ + Get recent updates for followed artists + + Query Parameters: + - limit: Number of updates to return (default: 20, max: 100) + - offset: Offset for pagination (default: 0) + - release_type: Filter by release type (album, single, ep, compilation) + - unread_only: Only return unread updates (true/false) + """ + try: + limit = min(request.args.get('limit', 20, type=int), 100) + offset = request.args.get('offset', 0, type=int) + release_type = request.args.get('release_type') + unread_only = request.args.get('unread_only', 'false').lower() == 'true' + + # Validate release type + if release_type and release_type not in ['album', 'single', 'ep', 'compilation']: + return error_response("Invalid release type", 400) + + updates = await update_tracker.get_user_updates( + get_current_user_id(), + limit=limit, + offset=offset, + release_type=release_type, + unread_only=unread_only + ) + + return success_response({ + 'updates': updates, + 'limit': limit, + 'offset': offset, + 'total': len(updates) + }) + + except Exception as e: + logger.error(f"Error getting recent updates: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/settings', methods=['GET']) +@login_required +async def get_settings(): + """ + Get user's update tracking settings + """ + try: + settings = await update_tracker.get_user_settings(get_current_user_id()) + return success_response(settings) + + except Exception as e: + logger.error(f"Error getting settings: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/settings', methods=['POST']) +@login_required +async def update_settings(): + """ + Update user's update tracking settings + + Request Body: + { + "enable_artist_monitoring": true, + "check_frequency": "daily", + "auto_download_favorites": false, + "auto_download_followed": false, + "max_auto_downloads_per_week": 5, + "quality_preference": "flac", + "storage_limit_mb": 10240, + "notification_channels": { + "in_app": true, + "push": false, + "email": false, + "discord": false + }, + "exclude_explicit": false, + "preferred_release_types": ["album", "ep", "single"] + } + """ + try: + data = request.get_json() + + if not data: + return error_response("Request body is required", 400) + + # Validate settings + if 'check_frequency' in data and data['check_frequency'] not in ['hourly', 'daily', 'weekly']: + return error_response("Invalid check frequency", 400) + + if 'quality_preference' in data and data['quality_preference'] not in ['flac', 'mp3_320', 'mp3_256', 'aac']: + return error_response("Invalid quality preference", 400) + + if 'max_auto_downloads_per_week' in data: + max_downloads = data['max_auto_downloads_per_week'] + if not isinstance(max_downloads, int) or max_downloads < 0 or max_downloads > 50: + return error_response("Invalid max auto downloads value", 400) + + if 'storage_limit_mb' in data: + storage_limit = data['storage_limit_mb'] + if not isinstance(storage_limit, int) or storage_limit < 100 or storage_limit > 102400: + return error_response("Invalid storage limit", 400) + + success = await update_tracker.update_user_settings(get_current_user_id(), data) + + if success: + return success_response({ + 'message': 'Settings updated successfully', + 'settings': data + }) + else: + return error_response("Failed to update settings", 500) + + except Exception as e: + logger.error(f"Error updating settings: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/auto-download/', methods=['POST']) +@login_required +async def auto_download_release(release_id): + """ + Trigger auto-download for a specific release + + Path Parameters: + - release_id: Spotify release ID + """ + try: + if not validate_spotify_id(release_id): + return error_response("Invalid release ID format", 400) + + success = await update_tracker.auto_download_release(get_current_user_id(), release_id) + + if success: + return success_response({ + 'message': 'Download queued successfully', + 'release_id': release_id + }) + else: + return error_response("Failed to queue download", 500) + + except Exception as e: + logger.error(f"Error auto-downloading release: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/stats', methods=['GET']) +@login_required +async def get_update_stats(): + """ + Get user's update tracking statistics + """ + try: + stats = await update_tracker.get_user_stats(get_current_user_id()) + return success_response(stats) + + except Exception as e: + logger.error(f"Error getting stats: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/followed-artists', methods=['GET']) +@login_required +async def get_followed_artists(): + """ + Get list of followed artists + + Query Parameters: + - limit: Number of artists to return (default: 50, max: 200) + - offset: Offset for pagination (default: 0) + - follow_level: Filter by follow level (casual, followed, favorite) + """ + try: + limit = min(request.args.get('limit', 50, type=int), 200) + offset = request.args.get('offset', 0, type=int) + follow_level = request.args.get('follow_level') + + # Validate follow level + if follow_level and follow_level not in ['casual', 'followed', 'favorite']: + return error_response("Invalid follow level", 400) + + artists = await update_tracker.get_followed_artists( + get_current_user_id(), + limit=limit, + offset=offset, + follow_level=follow_level + ) + + return success_response({ + 'artists': artists, + 'limit': limit, + 'offset': offset, + 'total': len(artists) + }) + + except Exception as e: + logger.error(f"Error getting followed artists: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/artist//follow-status', methods=['GET']) +@login_required +async def get_artist_follow_status(artist_id): + """ + Get follow status for a specific artist + + Path Parameters: + - artist_id: Spotify artist ID + """ + try: + if not validate_spotify_id(artist_id): + return error_response("Invalid artist ID format", 400) + + status = await update_tracker.get_artist_follow_status(get_current_user_id(), artist_id) + + if status: + return success_response(status) + else: + return success_response({ + 'is_following': False, + 'artist_id': artist_id + }) + + except Exception as e: + logger.error(f"Error getting artist follow status: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/artist/', methods=['PUT']) +@login_required +async def update_artist_follow(artist_id): + """ + Update follow settings for an artist + + Path Parameters: + - artist_id: Spotify artist ID + + Request Body: + { + "follow_level": "followed|favorite|casual", + "auto_download": true, + "preferred_quality": "flac", + "notification_preferences": { + "in_app": true, + "push": false, + "email": false, + "discord": false + } + } + """ + try: + if not validate_spotify_id(artist_id): + return error_response("Invalid artist ID format", 400) + + data = request.get_json() + + if not data: + return error_response("Request body is required", 400) + + # Validate follow level + if 'follow_level' in data and data['follow_level'] not in ['casual', 'followed', 'favorite']: + return error_response("Invalid follow level", 400) + + # Validate quality preference + if 'preferred_quality' in data and data['preferred_quality'] not in ['flac', 'mp3_320', 'mp3_256', 'aac']: + return error_response("Invalid quality preference", 400) + + success = await update_tracker.update_artist_follow( + get_current_user_id(), + artist_id, + data + ) + + if success: + return success_response({ + 'message': 'Artist follow settings updated', + 'artist_id': artist_id, + 'settings': data + }) + else: + return error_response("Failed to update artist follow settings", 500) + + except Exception as e: + logger.error(f"Error updating artist follow: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/release/', methods=['GET']) +@login_required +async def get_release_details(release_id): + """ + Get details for a specific release update + + Path Parameters: + - release_id: Spotify release ID + """ + try: + if not validate_spotify_id(release_id): + return error_response("Invalid release ID format", 400) + + release = await update_tracker.get_release_details(get_current_user_id(), release_id) + + if release: + return success_response(release) + else: + return error_response("Release not found", 404) + + except Exception as e: + logger.error(f"Error getting release details: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/release//mark-read', methods=['POST']) +@login_required +async def mark_release_read(release_id): + """ + Mark a release update as read + + Path Parameters: + - release_id: Spotify release ID + """ + try: + if not validate_spotify_id(release_id): + return error_response("Invalid release ID format", 400) + + success = await update_tracker.mark_release_read(get_current_user_id(), release_id) + + if success: + return success_response({ + 'message': 'Release marked as read', + 'release_id': release_id + }) + else: + return error_response("Failed to mark release as read", 500) + + except Exception as e: + logger.error(f"Error marking release as read: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/notifications', methods=['GET']) +@login_required +async def get_notifications(): + """ + Get user's update notifications + + Query Parameters: + - limit: Number of notifications to return (default: 20, max: 100) + - offset: Offset for pagination (default: 0) + - unread_only: Only return unread notifications (true/false) + """ + try: + limit = min(request.args.get('limit', 20, type=int), 100) + offset = request.args.get('offset', 0, type=int) + unread_only = request.args.get('unread_only', 'false').lower() == 'true' + + notifications = await update_tracker.get_notifications( + get_current_user_id(), + limit=limit, + offset=offset, + unread_only=unread_only + ) + + return success_response({ + 'notifications': notifications, + 'limit': limit, + 'offset': offset, + 'total': len(notifications) + }) + + except Exception as e: + logger.error(f"Error getting notifications: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/notifications/mark-all-read', methods=['POST']) +@login_required +async def mark_all_notifications_read(): + """ + Mark all notifications as read for the user + """ + try: + success = await update_tracker.mark_all_notifications_read(get_current_user_id()) + + if success: + return success_response({ + 'message': 'All notifications marked as read' + }) + else: + return error_response("Failed to mark notifications as read", 500) + + except Exception as e: + logger.error(f"Error marking all notifications as read: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/search/artists', methods=['GET']) +@login_required +async def search_artists_to_follow(): + """ + Search for artists to follow + + Query Parameters: + - q: Search query + - limit: Number of results to return (default: 10, max: 50) + """ + try: + query = request.args.get('q') + if not query: + return error_response("Search query is required", 400) + + limit = min(request.args.get('limit', 10, type=int), 50) + + artists = await update_tracker.search_artists(query, limit) + + return success_response({ + 'artists': artists, + 'query': query, + 'limit': limit, + 'total': len(artists) + }) + + except Exception as e: + logger.error(f"Error searching artists: {e}") + return error_response("Internal server error", 500) + + +@update_tracking_bp.route('/export/followed-artists', methods=['GET']) +@login_required +async def export_followed_artists(): + """ + Export followed artists as JSON or CSV + + Query Parameters: + - format: Export format (json|csv) - default: json + """ + try: + export_format = request.args.get('format', 'json').lower() + + if export_format not in ['json', 'csv']: + return error_response("Invalid export format. Must be json or csv", 400) + + data = await update_tracker.export_followed_artists(get_current_user_id(), export_format) + + if export_format == 'csv': + from flask import Response + return Response( + data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=followed_artists.csv'} + ) + else: + return success_response({'followed_artists': data}) + + except Exception as e: + logger.error(f"Error exporting followed artists: {e}") + return error_response("Internal server error", 500) + + +# Error handlers +@update_tracking_bp.errorhandler(404) +def not_found(error): + return error_response("Endpoint not found", 404) + + +@update_tracking_bp.errorhandler(500) +def internal_error(error): + return error_response("Internal server error", 500) diff --git a/src/swingmusic/api/upload.py b/src/swingmusic/api/upload.py new file mode 100644 index 00000000..a29cebf4 --- /dev/null +++ b/src/swingmusic/api/upload.py @@ -0,0 +1,392 @@ +""" +Contains all the file upload routes for manual music upload functionality. +""" + +import os +import shutil +import pathlib +from pathlib import Path +from datetime import datetime +from typing import List, Optional +import tempfile +import mimetypes + +from flask import request, jsonify +from flask_openapi3 import Tag +from pydantic import BaseModel, Field +from flask_openapi3 import APIBlueprint +from werkzeug.utils import secure_filename + +from swingmusic import settings +from swingmusic.config import UserConfig +from swingmusic.db.libdata import TrackTable +from swingmusic.api.auth import admin_required +from swingmusic.store.tracks import TrackStore +from swingmusic.utils.metadata import extract_metadata +from swingmusic.serializers.track import serialize_track + +tag = Tag(name="Upload", description="Manual music file upload functionality") +api = APIBlueprint("upload", __name__, url_prefix="/upload", abp_tags=[tag]) + +# Allowed audio file extensions +ALLOWED_EXTENSIONS = { + 'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'wma', 'opus', + 'aiff', 'au', 'ra', '3gp', 'amr', 'awb', 'dct', 'dvf', + 'm4p', 'mmf', 'mpc', 'msv', 'nmf', 'nsf', 'ogg', 'qcp', + 'ra', 'rm', 'sln', 'vox', 'wma', 'wv' +} + +# Maximum file size (100MB) +MAX_FILE_SIZE = 100 * 1024 * 1024 + + +def is_allowed_file(filename: str) -> bool: + """Check if file has an allowed audio extension.""" + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + + +def is_path_within_root_dirs(filepath: str) -> bool: + """ + Check if a filepath is within one of the configured root directories. + Prevents directory traversal attacks. + """ + config = UserConfig() + resolved_path = Path(filepath).resolve() + + for root_dir in config.rootDirs: + if root_dir == "$home": + root_path = Path.home().resolve() + else: + root_path = Path(root_dir).resolve() + + # Check if resolved_path is the root or a child of root + if resolved_path == root_path or root_path in resolved_path.parents: + return True + + return False + + +class UploadResponse(BaseModel): + success: bool = Field(description="Whether the upload was successful") + message: str = Field(description="Status message") + track_id: Optional[str] = Field(None, description="ID of the added track") + filename: Optional[str] = Field(None, description="Name of the uploaded file") + + +class BatchUploadResponse(BaseModel): + success: bool = Field(description="Whether the batch upload was successful") + message: str = Field(description="Status message") + uploaded_files: List[UploadResponse] = Field(description="List of upload results") + failed_files: List[str] = Field(description="List of failed files") + + +@api.post("/single") +@admin_required() +def upload_single_file(): + """ + Upload a single music file + + Uploads a single music file to the configured music folder and adds it to the library. + Supports drag-and-drop and file selection. + """ + try: + if 'file' not in request.files: + return jsonify({ + "success": False, + "message": "No file provided" + }), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({ + "success": False, + "message": "No file selected" + }), 400 + + # Check file extension + if not is_allowed_file(file.filename): + return jsonify({ + "success": False, + "message": f"File type not allowed. Supported formats: {', '.join(sorted(ALLOWED_EXTENSIONS))}" + }), 400 + + # Check file size + file.seek(0, os.SEEK_END) + file_size = file.tell() + file.seek(0) + + if file_size > MAX_FILE_SIZE: + return jsonify({ + "success": False, + "message": f"File too large. Maximum size is {MAX_FILE_SIZE // (1024*1024)}MB" + }), 400 + + # Get upload directory from settings or use first root directory + config = UserConfig() + upload_dir = None + + # Check if there's a specific upload directory configured + if hasattr(config, 'uploadDir') and config.uploadDir: + upload_dir = Path(config.uploadDir) + else: + # Use the first root directory as default + if config.rootDirs: + first_root = config.rootDirs[0] + if first_root == "$home": + upload_dir = Path.home() / "Music" + else: + upload_dir = Path(first_root) + else: + # Fallback to user's Music directory + upload_dir = Path.home() / "Music" + + # Ensure upload directory exists + upload_dir.mkdir(parents=True, exist_ok=True) + + # Secure the filename and create full path + filename = secure_filename(file.filename) + file_path = upload_dir / filename + + # Handle filename conflicts + counter = 1 + original_filename = filename + while file_path.exists(): + name, ext = os.path.splitext(original_filename) + filename = f"{name}_{counter}{ext}" + file_path = upload_dir / filename + counter += 1 + + # Save the file + file.save(file_path) + + # Extract metadata and add to library + try: + # This would trigger a library rescan for the specific file + # For now, we'll return the file info and let the frontend handle the refresh + track_info = { + "filepath": str(file_path), + "filename": filename, + "size": file_size + } + + return jsonify({ + "success": True, + "message": f"File '{filename}' uploaded successfully", + "filename": filename, + "filepath": str(file_path), + "track_info": track_info + }) + + except Exception as e: + # If metadata extraction fails, still return success for the upload + return jsonify({ + "success": True, + "message": f"File '{filename}' uploaded successfully (metadata extraction failed)", + "filename": filename, + "filepath": str(file_path), + "warning": f"Metadata extraction failed: {str(e)}" + }) + + except Exception as e: + return jsonify({ + "success": False, + "message": f"Upload failed: {str(e)}" + }), 500 + + +@api.post("/batch") +@admin_required() +def upload_multiple_files(): + """ + Upload multiple music files + + Uploads multiple music files to the configured music folder and adds them to the library. + Supports drag-and-drop of multiple files. + """ + try: + if 'files' not in request.files: + return jsonify({ + "success": False, + "message": "No files provided" + }), 400 + + files = request.files.getlist('files') + if not files: + return jsonify({ + "success": False, + "message": "No files selected" + }), 400 + + uploaded_files = [] + failed_files = [] + + # Get upload directory (same logic as single upload) + config = UserConfig() + upload_dir = None + + if hasattr(config, 'uploadDir') and config.uploadDir: + upload_dir = Path(config.uploadDir) + else: + if config.rootDirs: + first_root = config.rootDirs[0] + if first_root == "$home": + upload_dir = Path.home() / "Music" + else: + upload_dir = Path(first_root) + else: + upload_dir = Path.home() / "Music" + + upload_dir.mkdir(parents=True, exist_ok=True) + + for file in files: + if file.filename == '': + continue + + try: + # Check file extension + if not is_allowed_file(file.filename): + failed_files.append(f"{file.filename} - File type not allowed") + continue + + # Check file size + file.seek(0, os.SEEK_END) + file_size = file.tell() + file.seek(0) + + if file_size > MAX_FILE_SIZE: + failed_files.append(f"{file.filename} - File too large") + continue + + # Secure filename and handle conflicts + filename = secure_filename(file.filename) + file_path = upload_dir / filename + + counter = 1 + original_filename = filename + while file_path.exists(): + name, ext = os.path.splitext(original_filename) + filename = f"{name}_{counter}{ext}" + file_path = upload_dir / filename + counter += 1 + + # Save the file + file.save(file_path) + + uploaded_files.append({ + "success": True, + "message": f"File '{filename}' uploaded successfully", + "filename": filename, + "filepath": str(file_path), + "size": file_size + }) + + except Exception as e: + failed_files.append(f"{file.filename} - {str(e)}") + + total_files = len(uploaded_files) + len(failed_files) + success_count = len(uploaded_files) + + return jsonify({ + "success": len(uploaded_files) > 0, + "message": f"Uploaded {success_count} of {total_files} files", + "uploaded_files": uploaded_files, + "failed_files": failed_files + }) + + except Exception as e: + return jsonify({ + "success": False, + "message": f"Batch upload failed: {str(e)}" + }), 500 + + +@api.get("/config") +def get_upload_config(): + """ + Get upload configuration + + Returns the current upload configuration including allowed file types, + maximum file size, and upload directory. + """ + config = UserConfig() + + # Determine upload directory + upload_dir = None + if hasattr(config, 'uploadDir') and config.uploadDir: + upload_dir = config.uploadDir + elif config.rootDirs: + first_root = config.rootDirs[0] + if first_root == "$home": + upload_dir = str(Path.home() / "Music") + else: + upload_dir = first_root + else: + upload_dir = str(Path.home() / "Music") + + return jsonify({ + "allowed_extensions": sorted(list(ALLOWED_EXTENSIONS)), + "max_file_size": MAX_FILE_SIZE, + "max_file_size_mb": MAX_FILE_SIZE // (1024 * 1024), + "upload_directory": upload_dir, + "supported_formats": [ + {"ext": ext, "description": get_format_description(ext)} + for ext in sorted(ALLOWED_EXTENSIONS) + ] + }) + + +def get_format_description(extension: str) -> str: + """Get a user-friendly description for a file format.""" + descriptions = { + 'mp3': 'MP3 Audio', + 'flac': 'FLAC Lossless Audio', + 'wav': 'WAV Audio', + 'aac': 'AAC Audio', + 'm4a': 'M4A Audio', + 'ogg': 'OGG Vorbis Audio', + 'wma': 'WMA Audio', + 'opus': 'Opus Audio', + 'aiff': 'AIFF Audio', + 'au': 'AU Audio', + 'ra': 'RealAudio', + '3gp': '3GP Audio', + 'amr': 'AMR Audio', + 'awb': 'AWB Audio', + 'dct': 'DCT Audio', + 'dvf': 'DVF Audio', + 'm4p': 'M4P Audio', + 'mmf': 'MMF Audio', + 'mpc': 'MPC Audio', + 'msv': 'MSV Audio', + 'nmf': 'NMF Audio', + 'nsf': 'NSF Audio', + 'qcp': 'QCP Audio', + 'rm': 'RealMedia Audio', + 'sln': 'SLN Audio', + 'vox': 'VOX Audio', + 'wv': 'WavPack Audio' + } + return descriptions.get(extension.lower(), f'{extension.upper()} Audio') + + +@api.post("/rescan") +@admin_required() +def trigger_library_rescan(): + """ + Trigger library rescan + + Triggers a library rescan to detect newly uploaded files. + """ + try: + # This would integrate with the existing library scanning system + # For now, return a success response + return jsonify({ + "success": True, + "message": "Library rescan triggered successfully" + }) + except Exception as e: + return jsonify({ + "success": False, + "message": f"Failed to trigger library rescan: {str(e)}" + }), 500 diff --git a/src/swingmusic/app_builder.py b/src/swingmusic/app_builder.py index aa2edbd8..c10b057e 100644 --- a/src/swingmusic/app_builder.py +++ b/src/swingmusic/app_builder.py @@ -86,6 +86,33 @@ def load_endpoints(web: OpenAPI): # Auth web.register_api(swing_api.auth.api) + # Spotify Downloader + web.register_api(swing_api.spotify.api) + web.register_api(swing_api.spotify_settings.api) + + # Enhanced Search + from swingmusic.api.enhanced_search import register_enhanced_search_api + register_enhanced_search_api(web) + + # Universal Music Downloader + from swingmusic.api.universal_downloader import register_universal_downloader_api + register_universal_downloader_api(web) + + # Update Tracking + web.register_blueprint(swing_api.update_tracking.update_tracking_bp) + + # Audio Quality Management + web.register_blueprint(swing_api.audio_quality.audio_quality_bp) + + # Music Catalog Service + web.register_blueprint(swing_api.music_catalog.music_catalog_bp) + + # Advanced UX Service + web.register_blueprint(swing_api.advanced_ux.advanced_ux_bp) + + # Mobile Offline Service + web.register_blueprint(swing_api.mobile_offline.mobile_offline_bp) + def load_plugins(web: OpenAPI): # TODO: rework plugin support diff --git a/src/swingmusic/db/spotify.py b/src/swingmusic/db/spotify.py new file mode 100644 index 00000000..4b1ff50d --- /dev/null +++ b/src/swingmusic/db/spotify.py @@ -0,0 +1,1017 @@ +""" +Database models for Spotify downloader functionality +""" + +from datetime import datetime +from typing import Optional +from sqlalchemy import ( + JSON, + Boolean, + ForeignKey, + Integer, + String, + Text, + Float, + and_, + delete, + func, + insert, + select, + update, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from swingmusic.db.engine import DbEngine +from swingmusic.db import Base + + +class SpotifyDownloadTable(Base): + __tablename__ = "spotify_downloads" + + id: Mapped[int] = mapped_column(primary_key=True) + spotify_url: Mapped[str] = mapped_column(String(500), unique=True, nullable=False, index=True) + spotify_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + item_type: Mapped[str] = mapped_column(String(20), nullable=False) # track, album, playlist + title: Mapped[str] = mapped_column(String(500), nullable=False) + artist: Mapped[str] = mapped_column(String(500), nullable=False) + album: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + image_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + release_date: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + + # Download settings + quality: Mapped[str] = mapped_column(String(20), nullable=False, default='flac') + source: Mapped[str] = mapped_column(String(20), nullable=False, default='tidal') + output_dir: Mapped[str] = mapped_column(String(1000), nullable=False) + + # Download status + status: Mapped[str] = mapped_column(String(20), nullable=False, default='pending') + progress: Mapped[int] = mapped_column(Integer, default=0) + file_path: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True) + file_size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + + # Error handling + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + retry_count: Mapped[int] = mapped_column(Integer, default=0) + max_retries: Mapped[int] = mapped_column(Integer, default=3) + + # Metadata + metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + + # Timestamps + created_at: Mapped[float] = mapped_column(Float, nullable=False) + started_at: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + completed_at: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + updated_at: Mapped[float] = mapped_column(Float, nullable=False) + + # User association + user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id"), nullable=True) + + @classmethod + def create(cls, data: dict): + """Create a new Spotify download record""" + if 'created_at' not in data: + data['created_at'] = datetime.now().timestamp() + if 'updated_at' not in data: + data['updated_at'] = datetime.now().timestamp() + + return cls.insert_one(data) + + @classmethod + def get_by_id(cls, download_id: int): + """Get download by ID""" + result = cls.execute(select(cls).where(cls.id == download_id)) + res = next(result).scalar() + return res + + @classmethod + def get_by_spotify_id(cls, spotify_id: str): + """Get download by Spotify ID""" + result = cls.execute(select(cls).where(cls.spotify_id == spotify_id)) + res = next(result).scalar() + return res + + @classmethod + def get_by_url(cls, spotify_url: str): + """Get download by Spotify URL""" + result = cls.execute(select(cls).where(cls.spotify_url == spotify_url)) + res = next(result).scalar() + return res + + @classmethod + def get_pending_downloads(cls, limit: int = 50): + """Get pending downloads""" + result = cls.execute( + select(cls) + .where(cls.status == 'pending') + .order_by(cls.created_at) + .limit(limit) + ) + return [item for item in next(result).scalars()] + + @classmethod + def get_active_downloads(cls): + """Get currently active downloads""" + result = cls.execute( + select(cls) + .where(cls.status.in_(['downloading', 'processing'])) + .order_by(cls.started_at) + ) + return [item for item in next(result).scalars()] + + @classmethod + def get_download_history(cls, user_id: Optional[int] = None, limit: int = 100, offset: int = 0): + """Get download history with pagination""" + query = select(cls).where(cls.status.in_(['completed', 'failed', 'cancelled'])) + + if user_id: + query = query.where(cls.user_id == user_id) + + query = query.order_by(cls.created_at.desc()).offset(offset).limit(limit) + result = cls.execute(query) + return [item for item in next(result).scalars()] + + @classmethod + def update_status(cls, download_id: int, status: str, **kwargs): + """Update download status and related fields""" + update_data = { + 'status': status, + 'updated_at': datetime.now().timestamp() + } + update_data.update(kwargs) + + return cls.execute( + update(cls) + .where(cls.id == download_id) + .values(update_data), + commit=True + ) + + @classmethod + def update_progress(cls, download_id: int, progress: int): + """Update download progress""" + return cls.execute( + update(cls) + .where(cls.id == download_id) + .values({ + 'progress': progress, + 'updated_at': datetime.now().timestamp() + }), + commit=True + ) + + @classmethod + def increment_retry(cls, download_id: int): + """Increment retry count""" + return cls.execute( + update(cls) + .where(cls.id == download_id) + .values({ + 'retry_count': cls.retry_count + 1, + 'updated_at': datetime.now().timestamp() + }), + commit=True + ) + + @classmethod + def delete_completed(cls, older_than_days: int = 30): + """Delete completed downloads older than specified days""" + cutoff_time = datetime.now().timestamp() - (older_than_days * 24 * 60 * 60) + + return cls.execute( + delete(cls) + .where( + and_( + cls.status.in_(['completed', 'failed', 'cancelled']), + cls.completed_at < cutoff_time + ) + ), + commit=True + ) + + @classmethod + def get_statistics(cls): + """Get download statistics""" + result = cls.execute( + select( + cls.status, + func.count(cls.id).label('count'), + func.avg(cls.duration_ms).label('avg_duration') + ) + .group_by(cls.status) + ) + + stats = {} + for row in next(result): + stats[row.status] = { + 'count': row.count, + 'avg_duration_ms': row.avg_duration + } + + return stats + + +class SpotifyDownloadSourceTable(Base): + __tablename__ = "spotify_download_sources" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + display_name: Mapped[str] = mapped_column(String(100), nullable=False) + priority: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + config: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + created_at: Mapped[float] = mapped_column(Float, nullable=False) + updated_at: Mapped[float] = mapped_column(Float, nullable=False) + + @classmethod + def get_active_sources(cls): + """Get all active download sources ordered by priority""" + result = cls.execute( + select(cls) + .where(cls.is_active == True) + .order_by(cls.priority) + ) + return [item for item in next(result).scalars()] + + @classmethod + def get_by_name(cls, name: str): + """Get source by name""" + result = cls.execute(select(cls).where(cls.name == name)) + res = next(result).scalar() + return res + + @classmethod + def update_source(cls, name: str, **kwargs): + """Update source configuration""" + kwargs['updated_at'] = datetime.now().timestamp() + return cls.execute( + update(cls) + .where(cls.name == name) + .values(kwargs), + commit=True + ) + + +class SpotifyDownloadQueueTable(Base): + __tablename__ = "spotify_download_queue" + + id: Mapped[int] = mapped_column(primary_key=True) + download_id: Mapped[int] = mapped_column(ForeignKey("spotify_downloads.id"), nullable=False) + priority: Mapped[int] = mapped_column(Integer, default=0) + position: Mapped[int] = mapped_column(Integer, nullable=False) + added_at: Mapped[float] = mapped_column(Float, nullable=False) + started_at: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + + # Relationship to download + download = relationship("SpotifyDownloadTable", backref="queue_items") + + @classmethod + def add_to_queue(cls, download_id: int, priority: int = 0): + """Add download to queue""" + # Get current max position + result = cls.execute(select(func.max(cls.position))) + max_position = next(result).scalar() or 0 + + data = { + 'download_id': download_id, + 'priority': priority, + 'position': max_position + 1, + 'added_at': datetime.now().timestamp() + } + + return cls.insert_one(data) + + @classmethod + def get_next_item(cls): + """Get next item from queue""" + result = cls.execute( + select(cls) + .join(SpotifyDownloadTable) + .where( + and_( + SpotifyDownloadTable.status == 'pending', + cls.started_at.is_(None) + ) + ) + .order_by(cls.priority.desc(), cls.position) + .limit(1) + ) + res = next(result).scalar() + return res + + @classmethod + def remove_from_queue(cls, download_id: int): + """Remove item from queue""" + return cls.execute( + delete(cls).where(cls.download_id == download_id), + commit=True + ) + + @classmethod + def get_queue_length(cls): + """Get current queue length""" + result = cls.execute( + select(func.count(cls.id)) + .join(SpotifyDownloadTable) + .where(SpotifyDownloadTable.status == 'pending') + ) + return next(result).scalar() or 0 + + +# Create default download sources +def create_default_sources(): + """Create default download sources if they don't exist""" + default_sources = [ + { + 'name': 'tidal', + 'display_name': 'Tidal', + 'priority': 1, + 'is_active': True, + 'config': { + 'quality_preference': ['lossless', 'high', 'normal'], + 'formats': ['flac', 'mp3'] + } + }, + { + 'name': 'qobuz', + 'display_name': 'Qobuz', + 'priority': 2, + 'is_active': True, + 'config': { + 'quality_preference': ['lossless', 'high', 'normal'], + 'formats': ['flac', 'mp3'] + } + }, + { + 'name': 'amazon', + 'display_name': 'Amazon Music', + 'priority': 3, + 'is_active': False, # Disabled by default + 'config': { + 'quality_preference': ['high', 'normal'], + 'formats': ['mp3', 'aac'] + } + } + ] + + current_time = datetime.now().timestamp() + + for source_data in default_sources: + source_data['created_at'] = current_time + source_data['updated_at'] = current_time + + existing = SpotifyDownloadSourceTable.get_by_name(source_data['name']) + if not existing: + SpotifyDownloadSourceTable.insert_one(source_data) + + +# Add execute method (assuming it exists in the base class) +# This would need to be implemented based on the existing database pattern +for table_class in [SpotifyDownloadTable, SpotifyDownloadSourceTable, SpotifyDownloadQueueTable]: + if not hasattr(table_class, 'execute'): + @classmethod + def execute_method(cls, query, commit=False): + engine = DbEngine() + with engine.session() as session: + result = session.execute(query) + if commit: + session.commit() + return result + + table_class.execute = execute_method + table_class.insert_one = lambda data: table_class.execute(insert(table_class).values(data), commit=True) + + +class GlobalCatalogCacheTable(Base): + __tablename__ = "global_catalog_cache" + + id: Mapped[int] = mapped_column(primary_key=True) + spotify_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + item_type: Mapped[str] = mapped_column(String(50), nullable=False) # track, album, artist, playlist, search, artist_top_tracks, etc. + title: Mapped[str] = mapped_column(String(500), nullable=False) + artist: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + album: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + popularity: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + preview_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + image_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + release_date: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + explicit: Mapped[bool] = mapped_column(Boolean, default=False) + data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) # Full metadata JSON + cached_at: Mapped[float] = mapped_column(Float, nullable=False) + expires_at: Mapped[float] = mapped_column(Float, nullable=False) + + @classmethod + def create(cls, data: dict): + """Create a new catalog cache entry""" + if 'cached_at' not in data: + data['cached_at'] = datetime.now().timestamp() + + return cls.insert_one(data) + + @classmethod + def get_by_spotify_id(cls, spotify_id: str, item_type: str = None): + """Get cached item by Spotify ID and optionally type""" + query = select(cls).where(cls.spotify_id == spotify_id) + + if item_type: + query = query.where(cls.item_type == item_type) + + query = query.where(cls.expires_at > datetime.now().timestamp()) + query = query.order_by(cls.cached_at.desc()) + + result = cls.execute(query) + res = next(result).scalar() + return res + + @classmethod + def get_expired_entries(cls): + """Get all expired cache entries""" + result = cls.execute( + select(cls).where(cls.expires_at <= datetime.now().timestamp()) + ) + return [item for item in next(result).scalars()] + + @classmethod + def delete_expired(cls): + """Delete all expired cache entries""" + return cls.execute( + delete(cls).where(cls.expires_at <= datetime.now().timestamp()), + commit=True + ) + + @classmethod + def search_cached(cls, query: str, item_types: list = None, limit: int = 20): + """Search cached items by title or artist""" + query_filter = select(cls).where( + and_( + cls.expires_at > datetime.now().timestamp(), + or_( + cls.title.contains(query), + cls.artist.contains(query) + ) + ) + ) + + if item_types: + query_filter = query_filter.where(cls.item_type.in_(item_types)) + + query_filter = query_filter.order_by(cls.popularity.desc()).limit(limit) + + result = cls.execute(query_filter) + return [item for item in next(result).scalars()] + + @classmethod + def get_cache_stats(cls): + """Get cache statistics""" + result = cls.execute( + select( + cls.item_type, + func.count(cls.id).label('count'), + func.avg(cls.popularity).label('avg_popularity') + ) + .where(cls.expires_at > datetime.now().timestamp()) + .group_by(cls.item_type) + ) + + stats = {} + for row in next(result): + stats[row.item_type] = { + 'count': row.count, + 'avg_popularity': row.avg_popularity + } + + return stats + + +class UserCatalogPreferencesTable(Base): + __tablename__ = "user_catalog_preferences" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), nullable=False, unique=True) + show_explicit: Mapped[bool] = mapped_column(Boolean, default=True) + default_quality: Mapped[str] = mapped_column(String(20), default='flac') + auto_download: Mapped[bool] = mapped_column(Boolean, default=False) + show_suggestions: Mapped[bool] = mapped_column(Boolean, default=True) + preferred_genres: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) + excluded_genres: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) + max_search_results: Mapped[int] = mapped_column(Integer, default=20) + max_top_tracks: Mapped[int] = mapped_column(Integer, default=15) + max_albums_per_artist: Mapped[int] = mapped_column(Integer, default=20) + max_trending_results: Mapped[int] = mapped_column(Integer, default=20) + max_recommendations: Mapped[int] = mapped_column(Integer, default=20) + preferred_markets: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) + cache_ttl_preference: Mapped[int] = mapped_column(Integer, default=3600) # 1 hour + created_at: Mapped[float] = mapped_column(Float, nullable=False) + updated_at: Mapped[float] = mapped_column(Float, nullable=False) + + @classmethod + def get_or_create(cls, user_id: int): + """Get user preferences or create with defaults""" + result = cls.execute(select(cls).where(cls.user_id == user_id)) + existing = next(result).scalar() + + if existing: + return existing + + # Create with defaults + current_time = datetime.now().timestamp() + default_prefs = { + 'user_id': user_id, + 'show_explicit': True, + 'default_quality': 'flac', + 'auto_download': False, + 'show_suggestions': True, + 'max_search_results': 20, + 'max_top_tracks': 15, + 'max_albums_per_artist': 20, + 'max_trending_results': 20, + 'max_recommendations': 20, + 'preferred_markets': ['US'], + 'cache_ttl_preference': 3600, + 'created_at': current_time, + 'updated_at': current_time + } + + return cls.insert_one(default_prefs) + + @classmethod + def update_preferences(cls, user_id: int, preferences: dict): + """Update user catalog preferences""" + preferences['updated_at'] = datetime.now().timestamp() + + return cls.execute( + update(cls) + .where(cls.user_id == user_id) + .values(preferences), + commit=True + ) + + def save(self): + """Save current preferences state""" + self.updated_at = datetime.now().timestamp() + + return self.execute( + update(self.__class__) + .where(self.__class__.id == self.id) + .values({ + 'show_explicit': self.show_explicit, + 'default_quality': self.default_quality, + 'auto_download': self.auto_download, + 'show_suggestions': self.show_suggestions, + 'preferred_genres': self.preferred_genres, + 'excluded_genres': self.excluded_genres, + 'max_search_results': self.max_search_results, + 'max_top_tracks': self.max_top_tracks, + 'max_albums_per_artist': self.max_albums_per_artist, + 'max_trending_results': self.max_trending_results, + 'max_recommendations': self.max_recommendations, + 'preferred_markets': self.preferred_markets, + 'cache_ttl_preference': self.cache_ttl_preference, + 'updated_at': self.updated_at + }), + commit=True + ) + + +class UniversalDownloadTable(Base): + __tablename__ = "universal_downloads" + + id: Mapped[int] = mapped_column(primary_key=True) + url: Mapped[str] = mapped_column(String(1000), nullable=False, index=True) + service: Mapped[str] = mapped_column(String(50), nullable=False, index=True) # spotify, tidal, apple_music, etc. + service_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + item_type: Mapped[str] = mapped_column(String(20), nullable=False) # track, album, playlist, artist + title: Mapped[str] = mapped_column(String(500), nullable=False) + artist: Mapped[str] = mapped_column(String(500), nullable=False) + album: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + image_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + release_date: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + + # Download settings + quality: Mapped[str] = mapped_column(String(20), nullable=False, default='high') + output_dir: Mapped[str] = mapped_column(String(1000), nullable=False) + + # Download status + status: Mapped[str] = mapped_column(String(20), nullable=False, default='pending') + progress: Mapped[int] = mapped_column(Integer, default=0) + file_path: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True) + file_size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + + # Error handling + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + retry_count: Mapped[int] = mapped_column(Integer, default=0) + max_retries: Mapped[int] = mapped_column(Integer, default=3) + + # Metadata + metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + + # Timestamps + created_at: Mapped[float] = mapped_column(Float, nullable=False) + started_at: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + completed_at: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + updated_at: Mapped[float] = mapped_column(Float, nullable=False) + + # User association + user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id"), nullable=True) + + @classmethod + def create(cls, data: dict): + """Create a new universal download record""" + if 'created_at' not in data: + data['created_at'] = datetime.now().timestamp() + if 'updated_at' not in data: + data['updated_at'] = datetime.now().timestamp() + + return cls.insert_one(data) + + @classmethod + def get_by_id(cls, download_id: int): + """Get download by ID""" + result = cls.execute(select(cls).where(cls.id == download_id)) + res = next(result).scalar() + return res + + @classmethod + def get_by_service_id(cls, service: str, service_id: str): + """Get download by service and service ID""" + result = cls.execute( + select(cls) + .where(and_(cls.service == service, cls.service_id == service_id)) + ) + res = next(result).scalar() + return res + + @classmethod + def get_by_url(cls, url: str): + """Get download by URL""" + result = cls.execute(select(cls).where(cls.url == url)) + res = next(result).scalar() + return res + + @classmethod + def get_pending_downloads(cls, limit: int = 50): + """Get pending downloads""" + result = cls.execute( + select(cls) + .where(cls.status == 'pending') + .order_by(cls.created_at) + .limit(limit) + ) + return [item for item in next(result).scalars()] + + @classmethod + def get_active_downloads(cls): + """Get currently active downloads""" + result = cls.execute( + select(cls) + .where(cls.status.in_(['downloading', 'processing'])) + .order_by(cls.started_at) + ) + return [item for item in next(result).scalars()] + + @classmethod + def get_download_history(cls, user_id: Optional[int] = None, limit: int = 100, offset: int = 0): + """Get download history with pagination""" + query = select(cls).where(cls.status.in_(['completed', 'failed', 'cancelled'])) + + if user_id: + query = query.where(cls.user_id == user_id) + + query = query.order_by(cls.created_at.desc()).offset(offset).limit(limit) + result = cls.execute(query) + return [item for item in next(result).scalars()] + + @classmethod + def get_downloads_by_service(cls, service: str, limit: int = 50): + """Get downloads by service""" + result = cls.execute( + select(cls) + .where(cls.service == service) + .order_by(cls.created_at.desc()) + .limit(limit) + ) + return [item for item in next(result).scalars()] + + @classmethod + def update_status(cls, download_id: int, status: str, **kwargs): + """Update download status and related fields""" + update_data = { + 'status': status, + 'updated_at': datetime.now().timestamp() + } + update_data.update(kwargs) + + return cls.execute( + update(cls) + .where(cls.id == download_id) + .values(update_data), + commit=True + ) + + @classmethod + def update_progress(cls, download_id: int, progress: int): + """Update download progress""" + return cls.execute( + update(cls) + .where(cls.id == download_id) + .values({ + 'progress': progress, + 'updated_at': datetime.now().timestamp() + }), + commit=True + ) + + @classmethod + def increment_retry(cls, download_id: int): + """Increment retry count""" + return cls.execute( + update(cls) + .where(cls.id == download_id) + .values({ + 'retry_count': cls.retry_count + 1, + 'updated_at': datetime.now().timestamp() + }), + commit=True + ) + + @classmethod + def delete_completed(cls, older_than_days: int = 30): + """Delete completed downloads older than specified days""" + cutoff_time = datetime.now().timestamp() - (older_than_days * 24 * 60 * 60) + + return cls.execute( + delete(cls) + .where( + and_( + cls.status.in_(['completed', 'failed', 'cancelled']), + cls.completed_at < cutoff_time + ) + ), + commit=True + ) + + @classmethod + def get_statistics(cls): + """Get download statistics""" + result = cls.execute( + select( + cls.service, + cls.status, + func.count(cls.id).label('count'), + func.avg(cls.duration_ms).label('avg_duration') + ) + .group_by(cls.service, cls.status) + ) + + stats = {} + for row in next(result): + service = row.service + if service not in stats: + stats[service] = {} + stats[service][row.status] = { + 'count': row.count, + 'avg_duration_ms': row.avg_duration + } + + return stats + + +class UniversalDownloadSourceTable(Base): + __tablename__ = "universal_download_sources" + + id: Mapped[int] = mapped_column(primary_key=True) + service: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) # spotify, tidal, apple_music, etc. + display_name: Mapped[str] = mapped_column(String(100), nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + priority: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + supported_types: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) # track, album, playlist, artist + features: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) # metadata, download, playlist + config: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + created_at: Mapped[float] = mapped_column(Float, nullable=False) + updated_at: Mapped[float] = mapped_column(Float, nullable=False) + + @classmethod + def get_enabled_sources(cls): + """Get all enabled download sources ordered by priority""" + result = cls.execute( + select(cls) + .where(cls.enabled == True) + .order_by(cls.priority) + ) + return [item for item in next(result).scalars()] + + @classmethod + def get_by_service(cls, service: str): + """Get source by service name""" + result = cls.execute(select(cls).where(cls.service == service)) + res = next(result).scalar() + return res + + @classmethod + def update_source(cls, service: str, **kwargs): + """Update source configuration""" + kwargs['updated_at'] = datetime.now().timestamp() + + return cls.execute( + update(cls) + .where(cls.service == service) + .values(kwargs), + commit=True + ) + + +class UniversalDownloadQueueTable(Base): + __tablename__ = "universal_download_queue" + + id: Mapped[int] = mapped_column(primary_key=True) + download_id: Mapped[int] = mapped_column(ForeignKey("universal_downloads.id"), nullable=False) + priority: Mapped[int] = mapped_column(Integer, default=0) + position: Mapped[int] = mapped_column(Integer, nullable=False) + added_at: Mapped[float] = mapped_column(Float, nullable=False) + started_at: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + + # Relationship to download + download = relationship("UniversalDownloadTable", backref="queue_items") + + @classmethod + def add_to_queue(cls, download_id: int, priority: int = 0): + """Add download to queue""" + # Get current max position + result = cls.execute(select(func.max(cls.position))) + max_position = next(result).scalar() or 0 + + data = { + 'download_id': download_id, + 'priority': priority, + 'position': max_position + 1, + 'added_at': datetime.now().timestamp() + } + + return cls.insert_one(data) + + @classmethod + def get_next_item(cls): + """Get next item from queue""" + result = cls.execute( + select(cls) + .join(UniversalDownloadTable) + .where( + and_( + UniversalDownloadTable.status == 'pending', + cls.started_at.is_(None) + ) + ) + .order_by(cls.priority.desc(), cls.position) + .limit(1) + ) + res = next(result).scalar() + return res + + @classmethod + def remove_from_queue(cls, download_id: int): + """Remove item from queue""" + return cls.execute( + delete(cls).where(cls.download_id == download_id), + commit=True + ) + + @classmethod + def get_queue_length(cls): + """Get current queue length""" + result = cls.execute( + select(func.count(cls.id)) + .join(UniversalDownloadTable) + .where(UniversalDownloadTable.status == 'pending') + ) + return next(result).scalar() or 0 + + +# Create default universal download sources +def create_default_universal_sources(): + """Create default universal download sources if they don't exist""" + default_sources = [ + { + 'service': 'spotify', + 'display_name': 'Spotify', + 'enabled': True, + 'priority': 1, + 'supported_types': ['track', 'album', 'playlist', 'artist'], + 'features': ['metadata', 'download', 'playlist'], + 'config': { + 'quality_preference': ['lossless', 'high', 'medium', 'low'], + 'formats': ['flac', 'mp3', 'aac'] + } + }, + { + 'service': 'tidal', + 'display_name': 'Tidal', + 'enabled': True, + 'priority': 2, + 'supported_types': ['track', 'album', 'playlist', 'artist'], + 'features': ['metadata', 'download', 'playlist'], + 'config': { + 'quality_preference': ['lossless', 'high', 'medium', 'low'], + 'formats': ['flac', 'mp3', 'aac'] + } + }, + { + 'service': 'apple_music', + 'display_name': 'Apple Music', + 'enabled': True, + 'priority': 3, + 'supported_types': ['track', 'album', 'playlist', 'artist'], + 'features': ['metadata', 'download', 'playlist'], + 'config': { + 'quality_preference': ['lossless', 'high', 'medium', 'low'], + 'formats': ['flac', 'mp3', 'aac'] + } + }, + { + 'service': 'youtube_music', + 'display_name': 'YouTube Music', + 'enabled': True, + 'priority': 4, + 'supported_types': ['video', 'playlist', 'channel'], + 'features': ['metadata', 'download'], + 'config': { + 'quality_preference': ['high', 'medium', 'low'], + 'formats': ['mp3', 'webm'] + } + }, + { + 'service': 'youtube', + 'display_name': 'YouTube', + 'enabled': True, + 'priority': 5, + 'supported_types': ['video', 'playlist', 'channel'], + 'features': ['metadata', 'download'], + 'config': { + 'quality_preference': ['high', 'medium', 'low'], + 'formats': ['mp4', 'webm', 'mp3'] + } + }, + { + 'service': 'soundcloud', + 'display_name': 'SoundCloud', + 'enabled': True, + 'priority': 6, + 'supported_types': ['track', 'playlist', 'artist'], + 'features': ['metadata', 'download'], + 'config': { + 'quality_preference': ['high', 'medium', 'low'], + 'formats': ['mp3'] + } + }, + { + 'service': 'deezer', + 'display_name': 'Deezer', + 'enabled': False, # Disabled by default + 'priority': 7, + 'supported_types': ['track', 'album', 'playlist', 'artist'], + 'features': ['metadata', 'download', 'playlist'], + 'config': { + 'quality_preference': ['lossless', 'high', 'medium', 'low'], + 'formats': ['flac', 'mp3'] + } + }, + { + 'service': 'bandcamp', + 'display_name': 'Bandcamp', + 'enabled': False, # Disabled by default + 'priority': 8, + 'supported_types': ['track', 'album'], + 'features': ['metadata', 'download'], + 'config': { + 'quality_preference': ['lossless', 'high', 'medium', 'low'], + 'formats': ['flac', 'mp3', 'aac'] + } + } + ] + + current_time = datetime.now().timestamp() + + for source_data in default_sources: + source_data['created_at'] = current_time + source_data['updated_at'] = current_time + + existing = UniversalDownloadSourceTable.get_by_service(source_data['service']) + if not existing: + UniversalDownloadSourceTable.insert_one(source_data) + + +# Add execute method for new universal tables +for table_class in [UniversalDownloadTable, UniversalDownloadSourceTable, UniversalDownloadQueueTable]: + if not hasattr(table_class, 'execute'): + @classmethod + def execute_method(cls, query, commit=False): + engine = DbEngine() + with engine.session() as session: + result = session.execute(query) + if commit: + session.commit() + return result + + table_class.execute = execute_method + table_class.insert_one = lambda data: table_class.execute(insert(table_class).values(data), commit=True) diff --git a/src/swingmusic/logs/log.jsonl b/src/swingmusic/logs/log.jsonl new file mode 100644 index 00000000..1e608575 --- /dev/null +++ b/src/swingmusic/logs/log.jsonl @@ -0,0 +1,7 @@ +{"level": "WARNING", "message": "Spotify client credentials not configured, using demo mode", "timestamp": "2026-03-17T12:48:01.898787+00:00", "logger": "swingmusic.logger", "module": "spotify_metadata_client", "function": "__init__", "line": 92, "args": [], "who": "swingmusic.logger"} +{"level": "WARNING", "message": "Spotify client credentials not configured, using demo mode", "timestamp": "2026-03-17T12:48:34.157364+00:00", "logger": "swingmusic.logger", "module": "spotify_metadata_client", "function": "__init__", "line": 92, "args": [], "who": "swingmusic.logger"} +{"level": "WARNING", "message": "Spotify client credentials not configured, using demo mode", "timestamp": "2026-03-17T12:48:45.836758+00:00", "logger": "swingmusic.logger", "module": "spotify_metadata_client", "function": "__init__", "line": 92, "args": [], "who": "swingmusic.logger"} +{"level": "WARNING", "message": "Spotify client credentials not configured, using demo mode", "timestamp": "2026-03-17T12:48:55.986103+00:00", "logger": "swingmusic.logger", "module": "spotify_metadata_client", "function": "__init__", "line": 92, "args": [], "who": "swingmusic.logger"} +{"level": "WARNING", "message": "Spotify client credentials not configured, using demo mode", "timestamp": "2026-03-17T12:49:07.424983+00:00", "logger": "swingmusic.logger", "module": "spotify_metadata_client", "function": "__init__", "line": 92, "args": [], "who": "swingmusic.logger"} +{"level": "WARNING", "message": "Spotify client credentials not configured, using demo mode", "timestamp": "2026-03-17T12:49:30.754157+00:00", "logger": "swingmusic.logger", "module": "spotify_metadata_client", "function": "__init__", "line": 92, "args": [], "who": "swingmusic.logger"} +{"level": "WARNING", "message": "Spotify client credentials not configured, using demo mode", "timestamp": "2026-03-17T12:49:52.059294+00:00", "logger": "swingmusic.logger", "module": "spotify_metadata_client", "function": "__init__", "line": 92, "args": [], "who": "swingmusic.logger"} diff --git a/src/swingmusic/migrations/update_tracking_migration.py b/src/swingmusic/migrations/update_tracking_migration.py new file mode 100644 index 00000000..36538a41 --- /dev/null +++ b/src/swingmusic/migrations/update_tracking_migration.py @@ -0,0 +1,344 @@ +""" +Migration for Update Tracking System Tables + +This migration creates all the necessary tables for the artist update +tracking system, including follows, releases, notifications, and preferences. +""" + +import logging + +from swingmusic.db import db +from swingmusic.migrations.base import Migration + +logger = logging.getLogger(__name__) + + +class Migration001UpdateTracking(Migration): + """ + Create tables for the update tracking system + """ + + @staticmethod + def migrate(): + """ + Create all update tracking tables + """ + logger.info("Starting update tracking migration") + + try: + # Create artist_follows table + logger.info("Creating artist_follows table") + db.session.execute(""" + CREATE TABLE IF NOT EXISTS artist_follows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + artist_id TEXT NOT NULL UNIQUE, + artist_name TEXT NOT NULL, + follow_level TEXT NOT NULL DEFAULT 'followed', + auto_download_new_releases BOOLEAN DEFAULT FALSE, + preferred_quality TEXT DEFAULT 'flac', + notification_preferences TEXT DEFAULT '{}', + follow_date DATETIME DEFAULT CURRENT_TIMESTAMP, + last_check_date DATETIME NULL, + FOREIGN KEY (user_id) REFERENCES users (id) + ) + """) + + # Create release_updates table + logger.info("Creating release_updates table") + db.session.execute(""" + CREATE TABLE IF NOT EXISTS release_updates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + release_id TEXT NOT NULL UNIQUE, + artist_id TEXT NOT NULL, + artist_name TEXT NOT NULL, + release_title TEXT NOT NULL, + release_type TEXT NOT NULL, + release_date DATE NOT NULL, + spotify_url TEXT NOT NULL, + cover_image_url TEXT NULL, + total_tracks INTEGER NOT NULL, + popularity INTEGER DEFAULT 0, + explicit BOOLEAN DEFAULT FALSE, + discovered_at DATETIME DEFAULT CURRENT_TIMESTAMP, + processed_at DATETIME NULL, + download_status TEXT DEFAULT 'pending', + auto_downloaded BOOLEAN DEFAULT FALSE, + notification_sent BOOLEAN DEFAULT FALSE + ) + """) + + # Create update_notifications table + logger.info("Creating update_notifications table") + db.session.execute(""" + CREATE TABLE IF NOT EXISTS update_notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + release_id TEXT NOT NULL, + notification_type TEXT NOT NULL, + sent_at DATETIME DEFAULT CURRENT_TIMESTAMP, + opened_at DATETIME NULL, + action_taken TEXT NULL, + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (release_id) REFERENCES release_updates (release_id) + ) + """) + + # Create update_monitoring_preferences table + logger.info("Creating update_monitoring_preferences table") + db.session.execute(""" + CREATE TABLE IF NOT EXISTS update_monitoring_preferences ( + user_id INTEGER PRIMARY KEY, + enable_artist_monitoring BOOLEAN DEFAULT TRUE, + check_frequency TEXT DEFAULT 'daily', + auto_download_favorites BOOLEAN DEFAULT FALSE, + auto_download_followed BOOLEAN DEFAULT FALSE, + max_auto_downloads_per_week INTEGER DEFAULT 5, + quality_preference TEXT DEFAULT 'flac', + storage_limit_mb INTEGER DEFAULT 10240, + notification_channels TEXT DEFAULT '{}', + exclude_explicit BOOLEAN DEFAULT FALSE, + preferred_release_types TEXT DEFAULT '["album", "ep", "single"]', + FOREIGN KEY (user_id) REFERENCES users (id) + ) + """) + + # Create download_tasks table + logger.info("Creating download_tasks table") + db.session.execute(""" + CREATE TABLE IF NOT EXISTS download_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + release_id TEXT NOT NULL, + track_id TEXT NOT NULL, + track_title TEXT NOT NULL, + artist_name TEXT NOT NULL, + album_name TEXT NOT NULL, + spotify_url TEXT NOT NULL, + quality_preference TEXT DEFAULT 'flac', + status TEXT DEFAULT 'pending', + priority TEXT DEFAULT 'normal', + progress INTEGER DEFAULT 0, + file_path TEXT NULL, + error_message TEXT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + started_at DATETIME NULL, + completed_at DATETIME NULL, + auto_downloaded BOOLEAN DEFAULT FALSE, + added_to_library BOOLEAN DEFAULT FALSE, + FOREIGN KEY (release_id) REFERENCES release_updates (release_id) + ) + """) + + # Create artist_follow_history table + logger.info("Creating artist_follow_history table") + db.session.execute(""" + CREATE TABLE IF NOT EXISTS artist_follow_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + artist_id TEXT NOT NULL, + artist_name TEXT NOT NULL, + action TEXT NOT NULL, + old_level TEXT NULL, + new_level TEXT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + ) + """) + + # Create release_update_history table + logger.info("Creating release_update_history table") + db.session.execute(""" + CREATE TABLE IF NOT EXISTS release_update_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + release_id TEXT NOT NULL, + artist_id TEXT NOT NULL, + artist_name TEXT NOT NULL, + release_title TEXT NOT NULL, + release_type TEXT NOT NULL, + action TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + metadata TEXT NULL + ) + """) + + # Create update_tracking_stats table + logger.info("Creating update_tracking_stats table") + db.session.execute(""" + CREATE TABLE IF NOT EXISTS update_tracking_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + stat_date DATE NOT NULL, + total_followed_artists INTEGER DEFAULT 0, + new_releases_discovered INTEGER DEFAULT 0, + auto_downloads_completed INTEGER DEFAULT 0, + manual_downloads_completed INTEGER DEFAULT 0, + notifications_sent INTEGER DEFAULT 0, + notifications_opened INTEGER DEFAULT 0, + storage_used_mb INTEGER DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users (id), + UNIQUE(user_id, stat_date) + ) + """) + + # Create indexes for better performance + logger.info("Creating indexes") + + # Indexes for artist_follows + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_artist_follows_user_id ON artist_follows(user_id) + """) + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_artist_follows_artist_id ON artist_follows(artist_id) + """) + + # Indexes for release_updates + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_release_updates_artist_id ON release_updates(artist_id) + """) + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_release_updates_release_date ON release_updates(release_date) + """) + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_release_updates_discovered_at ON release_updates(discovered_at) + """) + + # Indexes for update_notifications + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_update_notifications_user_id ON update_notifications(user_id) + """) + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_update_notifications_release_id ON update_notifications(release_id) + """) + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_update_notifications_sent_at ON update_notifications(sent_at) + """) + + # Indexes for download_tasks + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_download_tasks_release_id ON download_tasks(release_id) + """) + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_download_tasks_status ON download_tasks(status) + """) + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_download_tasks_priority ON download_tasks(priority) + """) + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_download_tasks_created_at ON download_tasks(created_at) + """) + + # Indexes for history tables + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_artist_follow_history_user_id ON artist_follow_history(user_id) + """) + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_artist_follow_history_timestamp ON artist_follow_history(timestamp) + """) + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_release_update_history_release_id ON release_update_history(release_id) + """) + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_release_update_history_timestamp ON release_update_history(timestamp) + """) + + # Indexes for stats + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_update_tracking_stats_user_id ON update_tracking_stats(user_id) + """) + db.session.execute(""" + CREATE INDEX IF NOT EXISTS idx_update_tracking_stats_stat_date ON update_tracking_stats(stat_date) + """) + + # Commit the transaction + db.session.commit() + logger.info("Update tracking migration completed successfully") + + except Exception as e: + logger.error(f"Error during update tracking migration: {e}") + db.session.rollback() + raise + + +class Migration002UpdateTrackingTriggers(Migration): + """ + Create triggers for update tracking system + """ + + @staticmethod + def migrate(): + """ + Create triggers for automatic history tracking + """ + logger.info("Creating update tracking triggers") + + try: + # Trigger for artist follow history + db.session.execute(""" + CREATE TRIGGER IF NOT EXISTS artist_follow_history_insert + AFTER INSERT ON artist_follows + BEGIN + INSERT INTO artist_follow_history + (user_id, artist_id, artist_name, action, new_level, timestamp) + VALUES + (NEW.user_id, NEW.artist_id, NEW.artist_name, 'follow', NEW.follow_level, CURRENT_TIMESTAMP); + END + """) + + # Trigger for artist unfollow history + db.session.execute(""" + CREATE TRIGGER IF NOT EXISTS artist_follow_history_delete + AFTER DELETE ON artist_follows + BEGIN + INSERT INTO artist_follow_history + (user_id, artist_id, artist_name, action, old_level, timestamp) + VALUES + (OLD.user_id, OLD.artist_id, OLD.artist_name, 'unfollow', OLD.follow_level, CURRENT_TIMESTAMP); + END + """) + + # Trigger for artist follow level change + db.session.execute(""" + CREATE TRIGGER IF NOT EXISTS artist_follow_history_update + AFTER UPDATE ON artist_follows + WHEN OLD.follow_level != NEW.follow_level + BEGIN + INSERT INTO artist_follow_history + (user_id, artist_id, artist_name, action, old_level, new_level, timestamp) + VALUES + (NEW.user_id, NEW.artist_id, NEW.artist_name, 'level_change', OLD.follow_level, NEW.follow_level, CURRENT_TIMESTAMP); + END + """) + + # Trigger for release update discovery + db.session.execute(""" + CREATE TRIGGER IF NOT EXISTS release_update_discovered + AFTER INSERT ON release_updates + BEGIN + INSERT INTO release_update_history + (release_id, artist_id, artist_name, release_title, release_type, action, timestamp) + VALUES + (NEW.release_id, NEW.artist_id, NEW.artist_name, NEW.release_title, NEW.release_type, 'discovered', CURRENT_TIMESTAMP); + END + """) + + # Trigger for release update download completion + db.session.execute(""" + CREATE TRIGGER IF NOT EXISTS release_update_downloaded + AFTER UPDATE ON release_updates + WHEN OLD.download_status != 'completed' AND NEW.download_status = 'completed' + BEGIN + INSERT INTO release_update_history + (release_id, artist_id, artist_name, release_title, release_type, action, timestamp, metadata) + VALUES + (NEW.release_id, NEW.artist_id, NEW.artist_name, NEW.release_title, NEW.release_type, 'downloaded', CURRENT_TIMESTAMP, + json_object('auto_downloaded', NEW.auto_downloaded)); + END + """) + + db.session.commit() + logger.info("Update tracking triggers created successfully") + + except Exception as e: + logger.error(f"Error creating update tracking triggers: {e}") + db.session.rollback() + raise diff --git a/src/swingmusic/models/update_tracking.py b/src/swingmusic/models/update_tracking.py new file mode 100644 index 00000000..85ccd902 --- /dev/null +++ b/src/swingmusic/models/update_tracking.py @@ -0,0 +1,230 @@ +""" +Update Tracking Database Models + +This module contains the database models for the artist update tracking system, +including artist follows, release updates, notifications, and user preferences. +""" + +import datetime +from typing import Optional, Dict, Any +from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, Date, JSON, DECIMAL +from sqlalchemy.orm import relationship +from sqlalchemy.ext.declarative import declarative_base + +from swingmusic.db.base import Base + + +class ArtistFollow(Base): + """ + Represents a user following an artist for update tracking + """ + __tablename__ = 'artist_follows' + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + artist_id = Column(String(100), nullable=False, unique=True) # Spotify artist ID + artist_name = Column(String(255), nullable=False) + follow_level = Column(String(20), nullable=False, default='followed') # 'favorite', 'followed', 'casual' + auto_download_new_releases = Column(Boolean, default=False) + preferred_quality = Column(String(20), default='flac') + notification_preferences = Column(JSON, default=dict) # {in_app: true, push: false, email: false} + follow_date = Column(DateTime, default=datetime.datetime.utcnow) + last_check_date = Column(DateTime, nullable=True) + + # Relationships + user = relationship("User", back_populates="artist_follows") + release_updates = relationship("ReleaseUpdate", back_populates="artist_follow") + + def __repr__(self): + return f"" + + +class ReleaseUpdate(Base): + """ + Represents a new release discovered from a followed artist + """ + __tablename__ = 'release_updates' + + id = Column(Integer, primary_key=True) + release_id = Column(String(100), nullable=False, unique=True) # Spotify release ID + artist_id = Column(String(100), nullable=False) # Spotify artist ID + artist_name = Column(String(255), nullable=False) + release_title = Column(String(255), nullable=False) + release_type = Column(String(20), nullable=False) # 'album', 'single', 'ep', 'compilation' + release_date = Column(Date, nullable=False) + spotify_url = Column(Text, nullable=False) + cover_image_url = Column(Text, nullable=True) + total_tracks = Column(Integer, nullable=False) + popularity = Column(Integer, default=0) + explicit = Column(Boolean, default=False) + discovered_at = Column(DateTime, default=datetime.datetime.utcnow) + processed_at = Column(DateTime, nullable=True) + download_status = Column(String(20), default='pending') # 'pending', 'queued', 'downloading', 'completed', 'failed' + auto_downloaded = Column(Boolean, default=False) + notification_sent = Column(Boolean, default=False) + + # Relationships + artist_follow = relationship("ArtistFollow", back_populates="release_updates") + download_tasks = relationship("DownloadTask", back_populates="release_update") + notifications = relationship("UpdateNotification", back_populates="release_update") + + def __repr__(self): + return f"" + + +class UpdateNotification(Base): + """ + Represents notifications sent to users about new releases + """ + __tablename__ = 'update_notifications' + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + release_id = Column(String(100), ForeignKey('release_updates.release_id'), nullable=False) + notification_type = Column(String(50), nullable=False) # 'new_release', 'artist_update', 'back_in_stock' + sent_at = Column(DateTime, default=datetime.datetime.utcnow) + opened_at = Column(DateTime, nullable=True) + action_taken = Column(String(50), nullable=True) # 'downloaded', 'played', 'dismissed' + + # Relationships + user = relationship("User") + release_update = relationship("ReleaseUpdate", back_populates="notifications") + + def __repr__(self): + return f"" + + +class UpdateMonitoringPreferences(Base): + """ + User preferences for update monitoring + """ + __tablename__ = 'update_monitoring_preferences' + + user_id = Column(Integer, ForeignKey('users.id'), primary_key=True) + enable_artist_monitoring = Column(Boolean, default=True) + check_frequency = Column(String(20), default='daily') # 'hourly', 'daily', 'weekly' + auto_download_favorites = Column(Boolean, default=False) + auto_download_followed = Column(Boolean, default=False) + max_auto_downloads_per_week = Column(Integer, default=5) + quality_preference = Column(String(20), default='flac') + storage_limit_mb = Column(Integer, default=10240) + notification_channels = Column(JSON, default=dict) # {in_app: true, push: false, email: false, discord: false} + exclude_explicit = Column(Boolean, default=False) + preferred_release_types = Column(JSON, default=list) # ['album', 'ep', 'single'] + + # Relationships + user = relationship("User", back_populates="update_preferences") + + def __repr__(self): + return f"" + + +class DownloadTask(Base): + """ + Represents download tasks created from release updates + """ + __tablename__ = 'download_tasks' + + id = Column(Integer, primary_key=True) + release_id = Column(String(100), ForeignKey('release_updates.release_id'), nullable=False) + track_id = Column(String(100), nullable=False) # Spotify track ID + track_title = Column(String(255), nullable=False) + artist_name = Column(String(255), nullable=False) + album_name = Column(String(255), nullable=False) + spotify_url = Column(Text, nullable=False) + quality_preference = Column(String(20), default='flac') + status = Column(String(20), default='pending') # 'pending', 'queued', 'downloading', 'completed', 'failed' + priority = Column(String(20), default='normal') # 'low', 'normal', 'high', 'urgent' + progress = Column(Integer, default=0) # 0-100 + file_path = Column(Text, nullable=True) + error_message = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + started_at = Column(DateTime, nullable=True) + completed_at = Column(DateTime, nullable=True) + auto_downloaded = Column(Boolean, default=False) + added_to_library = Column(Boolean, default=False) + + # Relationships + release_update = relationship("ReleaseUpdate", back_populates="download_tasks") + + def __repr__(self): + return f"" + + +class ArtistFollowHistory(Base): + """ + Historical tracking of artist follows for analytics + """ + __tablename__ = 'artist_follow_history' + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + artist_id = Column(String(100), nullable=False) + artist_name = Column(String(255), nullable=False) + action = Column(String(20), nullable=False) # 'follow', 'unfollow', 'level_change' + old_level = Column(String(20), nullable=True) + new_level = Column(String(20), nullable=True) + timestamp = Column(DateTime, default=datetime.datetime.utcnow) + + # Relationships + user = relationship("User") + + def __repr__(self): + return f"" + + +class ReleaseUpdateHistory(Base): + """ + Historical tracking of release updates for analytics + """ + __tablename__ = 'release_update_history' + + id = Column(Integer, primary_key=True) + release_id = Column(String(100), nullable=False) + artist_id = Column(String(100), nullable=False) + artist_name = Column(String(255), nullable=False) + release_title = Column(String(255), nullable=False) + release_type = Column(String(20), nullable=False) + action = Column(String(20), nullable=False) # 'discovered', 'downloaded', 'notification_sent', 'completed' + timestamp = Column(DateTime, default=datetime.datetime.utcnow) + metadata = Column(JSON, nullable=True) # Additional data about the action + + def __repr__(self): + return f"" + + +class UpdateTrackingStats(Base): + """ + Aggregated statistics for update tracking + """ + __tablename__ = 'update_tracking_stats' + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + stat_date = Column(Date, nullable=False) + total_followed_artists = Column(Integer, default=0) + new_releases_discovered = Column(Integer, default=0) + auto_downloads_completed = Column(Integer, default=0) + manual_downloads_completed = Column(Integer, default=0) + notifications_sent = Column(Integer, default=0) + notifications_opened = Column(Integer, default=0) + storage_used_mb = Column(Integer, default=0) + + # Relationships + user = relationship("User") + + def __repr__(self): + return f"" + + +# Update the User model to include the new relationships +# This would need to be added to the User model in user.py: +# +# from swingmusic.models.update_tracking import ArtistFollow, UpdateMonitoringPreferences +# +# class User(Base): +# # ... existing fields ... +# +# # Update tracking relationships +# artist_follows = relationship("ArtistFollow", back_populates="user") +# update_preferences = relationship("UpdateMonitoringPreferences", back_populates="user", uselist=False) diff --git a/src/swingmusic/services/advanced_ux_service.py b/src/swingmusic/services/advanced_ux_service.py new file mode 100644 index 00000000..1eb9b9c9 --- /dev/null +++ b/src/swingmusic/services/advanced_ux_service.py @@ -0,0 +1,1021 @@ +""" +Advanced UX Service + +This service provides enhanced user experience features including: +- AI-powered search suggestions and recommendations +- Enhanced search interface with smart filters +- Download integration throughout the UI +- Contextual suggestions based on user behavior +- Personalized content discovery +""" + +import asyncio +import datetime +import json +import logging +import re +from typing import Dict, List, Optional, Tuple, Any +from dataclasses import dataclass, asdict +from enum import Enum +from collections import defaultdict, Counter + +from sqlalchemy import select, func, and_, or_, desc +from sqlalchemy.orm import Session + +from swingmusic.db import db +from swingmusic.models.track import Track +from swingmusic.models.artist import Artist +from swingmusic.models.album import Album +from swingmusic.models.playlist import Playlist +from swingmusic.models.playlog import Playlog +from swingmusic.services.music_catalog import music_catalog_service +from swingmusic.services.universal_music_downloader import universal_music_downloader +from swingmusic.utils.ai_recommender import AIRecommender +from swingmusic.utils.behavior_tracker import BehaviorTracker + +logger = logging.getLogger(__name__) + + +class SuggestionType(Enum): + """Types of suggestions""" + SEARCH_QUERY = "search_query" + TRACK = "track" + ARTIST = "artist" + ALBUM = "album" + PLAYLIST = "playlist" + GENRE = "genre" + MOOD = "mood" + ACTIVITY = "activity" + + +class SearchContext(Enum): + """Search context for suggestions""" + GENERAL = "general" + DISCOVERY = "discovery" + DOWNLOAD = "download" + PLAYLIST = "playlist" + OFFLINE = "offline" + SOCIAL = "social" + + +@dataclass +class SearchSuggestion: + """Search suggestion with metadata""" + id: str + type: SuggestionType + title: str + subtitle: Optional[str] + image_url: Optional[str] + url: Optional[str] + metadata: Dict[str, Any] + relevance_score: float + context: SearchContext + created_at: datetime.datetime + + +@dataclass +class UserBehavior: + """User behavior patterns for personalization""" + user_id: int + favorite_genres: List[str] + favorite_artists: List[str] + listening_patterns: Dict[str, Any] + search_history: List[Dict[str, Any]] + download_preferences: Dict[str, Any] + interaction_patterns: Dict[str, Any] + last_updated: datetime.datetime + + +@dataclass +class SearchFilter: + """Enhanced search filter""" + filter_id: str + name: str + type: str # genre, mood, year, quality, duration, etc. + options: List[Dict[str, Any]] + is_active: bool + is_multi_select: bool + + +class AdvancedUXService: + """Service for enhanced user experience features""" + + def __init__(self): + self.ai_recommender = AIRecommender() + self.behavior_tracker = BehaviorTracker() + self.search_history = defaultdict(list) + self.suggestion_cache = {} + self.user_behaviors = {} + + # Search configuration + self.max_suggestions = 10 + self.search_history_limit = 100 + self.behavior_update_interval = 3600 # 1 hour + + async def get_search_suggestions(self, user_id: int, query: str, context: SearchContext = SearchContext.GENERAL, limit: int = 10) -> List[SearchSuggestion]: + """ + Get intelligent search suggestions based on query and context + + Args: + user_id: User ID + query: Search query + context: Search context + limit: Maximum suggestions to return + + Returns: + List of search suggestions + """ + try: + # Clean and normalize query + clean_query = self._clean_search_query(query) + + if len(clean_query) < 2: + return await self._get_default_suggestions(user_id, context, limit) + + # Generate suggestions from multiple sources + suggestions = [] + + # 1. Track suggestions from local library + local_tracks = await self._get_local_track_suggestions(user_id, clean_query, limit // 3) + suggestions.extend(local_tracks) + + # 2. Artist suggestions + artists = await self._get_artist_suggestions(user_id, clean_query, limit // 4) + suggestions.extend(artists) + + # 3. Album suggestions + albums = await self._get_album_suggestions(user_id, clean_query, limit // 4) + suggestions.extend(albums) + + # 4. Global catalog suggestions + if context in [SearchContext.DISCOVERY, SearchContext.DOWNLOAD]: + global_suggestions = await self._get_global_suggestions(user_id, clean_query, limit // 3) + suggestions.extend(global_suggestions) + + # 5. Behavior-based suggestions + behavior_suggestions = await self._get_behavior_suggestions(user_id, clean_query, context, limit // 4) + suggestions.extend(behavior_suggestions) + + # Sort by relevance and limit + suggestions.sort(key=lambda x: x.relevance_score, reverse=True) + suggestions = suggestions[:limit] + + # Log search for learning + await self._log_search_query(user_id, query, context, suggestions) + + return suggestions + + except Exception as e: + logger.error(f"Error getting search suggestions: {e}") + return [] + + async def get_discovery_recommendations(self, user_id: int, recommendation_type: str = "mixed", limit: int = 20) -> List[SearchSuggestion]: + """ + Get personalized discovery recommendations + + Args: + user_id: User ID + recommendation_type: Type of recommendations (tracks, artists, albums, mixed) + limit: Maximum recommendations to return + + Returns: + List of discovery recommendations + """ + try: + # Get user behavior data + behavior = await self._get_user_behavior(user_id) + + recommendations = [] + + # Generate recommendations based on type + if recommendation_type in ["tracks", "mixed"]: + track_recs = await self._get_track_recommendations(user_id, behavior, limit // 2) + recommendations.extend(track_recs) + + if recommendation_type in ["artists", "mixed"]: + artist_recs = await self._get_artist_recommendations(user_id, behavior, limit // 2) + recommendations.extend(artist_recs) + + if recommendation_type in ["albums", "mixed"]: + album_recs = await self._get_album_recommendations(user_id, behavior, limit // 2) + recommendations.extend(album_recs) + + # Sort by relevance and limit + recommendations.sort(key=lambda x: x.relevance_score, reverse=True) + recommendations = recommendations[:limit] + + return recommendations + + except Exception as e: + logger.error(f"Error getting discovery recommendations: {e}") + return [] + + async def get_contextual_suggestions(self, user_id: int, current_track_id: str, context_type: str) -> List[SearchSuggestion]: + """ + Get contextual suggestions based on current track + + Args: + user_id: User ID + current_track_id: Currently playing track ID + context_type: Type of context (similar, same_artist, same_genre, etc.) + + Returns: + List of contextual suggestions + """ + try: + suggestions = [] + + # Get current track information + current_track = await self._get_track_info(current_track_id) + if not current_track: + return [] + + # Generate suggestions based on context type + if context_type == "similar": + similar_suggestions = await self._get_similar_track_suggestions(user_id, current_track, 10) + suggestions.extend(similar_suggestions) + + elif context_type == "same_artist": + artist_suggestions = await self._get_same_artist_suggestions(user_id, current_track['artist'], 10) + suggestions.extend(artist_suggestions) + + elif context_type == "same_genre": + genre_suggestions = await self._get_same_genre_suggestions(user_id, current_track, 10) + suggestions.extend(genre_suggestions) + + elif context_type == "popular": + popular_suggestions = await self._get_popular_suggestions(user_id, 10) + suggestions.extend(popular_suggestions) + + return suggestions[:10] + + except Exception as e: + logger.error(f"Error getting contextual suggestions: {e}") + return [] + + async def get_download_suggestions(self, user_id: int, query: str = "", limit: int = 15) -> List[SearchSuggestion]: + """ + Get download-specific suggestions with universal downloader integration + + Args: + user_id: User ID + query: Search query + limit: Maximum suggestions to return + + Returns: + List of download suggestions + """ + try: + suggestions = [] + + # If query is provided, search for matching content + if query: + # Search global catalog + catalog_results = await music_catalog_service.search_global_catalog(query, "all", limit) + + # Convert to suggestions + for track in catalog_results.tracks[:limit // 2]: + suggestion = SearchSuggestion( + id=f"track_{track.spotify_id}", + type=SuggestionType.TRACK, + title=track.title, + subtitle=f"{track.artist} • {track.album}", + image_url=track.image_url, + url=f"/download/{track.spotify_id}", + metadata={ + 'spotify_id': track.spotify_id, + 'artist': track.artist, + 'album': track.album, + 'duration': track.duration_ms, + 'popularity': track.popularity, + 'preview_url': track.preview_url + }, + relevance_score=self._calculate_download_relevance(track, user_id), + context=SearchContext.DOWNLOAD, + created_at=datetime.datetime.utcnow() + ) + suggestions.append(suggestion) + + # Add artist suggestions + for artist in catalog_results.artists[:limit // 4]: + suggestion = SearchSuggestion( + id=f"artist_{artist.spotify_id}", + type=SuggestionType.ARTIST, + title=artist.title, + subtitle=f"{artist.popularity} popularity", + image_url=artist.image_url, + url=f"/artist/{artist.spotify_id}", + metadata={ + 'spotify_id': artist.spotify_id, + 'popularity': artist.popularity, + 'followers': artist.data.get('followers', 0) + }, + relevance_score=self._calculate_download_relevance(artist, user_id), + context=SearchContext.DOWNLOAD, + created_at=datetime.datetime.utcnow() + ) + suggestions.append(suggestion) + + # Add album suggestions + for album in catalog_results.albums[:limit // 4]: + suggestion = SearchSuggestion( + id=f"album_{album.spotify_id}", + type=SuggestionType.ALBUM, + title=album.title, + subtitle=f"{album.artist} • {album.data.get('total_tracks', 0)} tracks", + image_url=album.image_url, + url=f"/album/{album.spotify_id}", + metadata={ + 'spotify_id': album.spotify_id, + 'artist': album.artist, + 'total_tracks': album.data.get('total_tracks', 0), + 'release_date': album.release_date, + 'album_type': album.data.get('album_type', 'album') + }, + relevance_score=self._calculate_download_relevance(album, user_id), + context=SearchContext.DOWNLOAD, + created_at=datetime.datetime.utcnow() + ) + suggestions.append(suggestion) + + # Add trending/popular suggestions if no query + if not query: + trending_suggestions = await self._get_trending_download_suggestions(user_id, limit) + suggestions.extend(trending_suggestions) + + # Sort by relevance and limit + suggestions.sort(key=lambda x: x.relevance_score, reverse=True) + suggestions = suggestions[:limit] + + return suggestions + + except Exception as e: + logger.error(f"Error getting download suggestions: {e}") + return [] + + async def get_enhanced_search_filters(self, user_id: int) -> List[SearchFilter]: + """ + Get enhanced search filters with user personalization + + Args: + user_id: User ID + + Returns: + List of enhanced search filters + """ + try: + filters = [] + + # Get user behavior for personalization + behavior = await self._get_user_behavior(user_id) + + # Genre filter + genre_options = [] + popular_genres = await self._get_popular_genres(user_id) + for genre in popular_genres: + genre_options.append({ + 'value': genre, + 'label': genre.title(), + 'count': await self._get_genre_track_count(genre), + 'is_favorite': genre in behavior.favorite_genres + }) + + filters.append(SearchFilter( + filter_id="genre", + name="Genre", + type="genre", + options=genre_options, + is_active=False, + is_multi_select=True + )) + + # Mood filter + mood_options = [ + {'value': 'energetic', 'label': 'Energetic', 'icon': 'zap'}, + {'value': 'relaxed', 'label': 'Relaxed', 'icon': 'leaf'}, + {'value': 'happy', 'label': 'Happy', 'icon': 'smile'}, + {'value': 'sad', 'label': 'Sad', 'icon': 'frown'}, + {'value': 'focused', 'label': 'Focused', 'icon': 'brain'}, + {'value': 'workout', 'label': 'Workout', 'icon': 'dumbbell'} + ] + + filters.append(SearchFilter( + filter_id="mood", + name="Mood", + type="mood", + options=mood_options, + is_active=False, + is_multi_select=False + )) + + # Year filter + current_year = datetime.datetime.now().year + year_options = [] + for year_offset in range(0, 10): + year = current_year - year_offset + year_options.append({ + 'value': str(year), + 'label': str(year), + 'count': await self._get_year_track_count(year) + }) + + filters.append(SearchFilter( + filter_id="year", + name="Year", + type="year", + options=year_options, + is_active=False, + is_multi_select=True + )) + + # Quality filter + quality_options = [ + {'value': 'lossless', 'label': 'Lossless (FLAC)', 'icon': 'gem'}, + {'value': 'high', 'label': 'High (320kbps)', 'icon': 'star'}, + {'value': 'medium', 'label': 'Medium (256kbps)', 'icon': 'music'}, + {'value': 'low', 'label': 'Low (128kbps)', 'icon': 'headphones'} + ] + + filters.append(SearchFilter( + filter_id="quality", + name="Audio Quality", + type="quality", + options=quality_options, + is_active=False, + is_multi_select=False + )) + + # Duration filter + duration_options = [ + {'value': 'short', 'label': 'Short (< 2 min)', 'max_seconds': 120}, + {'value': 'medium', 'label': 'Medium (2-4 min)', 'min_seconds': 120, 'max_seconds': 240}, + {'value': 'long', 'label': 'Long (> 4 min)', 'min_seconds': 240} + ] + + filters.append(SearchFilter( + filter_id="duration", + name="Duration", + type="duration", + options=duration_options, + is_active=False, + is_multi_select=False + )) + + return filters + + except Exception as e: + logger.error(f"Error getting enhanced search filters: {e}") + return [] + + async def update_user_behavior(self, user_id: int, interaction_data: Dict[str, Any]): + """ + Update user behavior based on interactions + + Args: + user_id: User ID + interaction_data: Interaction data + """ + try: + # Get existing behavior + behavior = await self._get_user_behavior(user_id) + + # Update based on interaction type + interaction_type = interaction_data.get('type') + + if interaction_type == 'search': + await self._update_search_behavior(behavior, interaction_data) + elif interaction_type == 'play': + await self._update_play_behavior(behavior, interaction_data) + elif interaction_type == 'download': + await self._update_download_behavior(behavior, interaction_data) + elif interaction_type == 'like': + await self._update_like_behavior(behavior, interaction_data) + + # Save updated behavior + behavior.last_updated = datetime.datetime.utcnow() + self.user_behaviors[user_id] = behavior + + # Periodically save to database + if datetime.datetime.utcnow().timestamp() % self.behavior_update_interval < 60: + await self._save_user_behavior(behavior) + + except Exception as e: + logger.error(f"Error updating user behavior: {e}") + + # Private helper methods + + def _clean_search_query(self, query: str) -> str: + """Clean and normalize search query""" + if not query: + return "" + + # Remove special characters and normalize whitespace + clean_query = re.sub(r'[^\w\s]', '', query.lower()) + clean_query = re.sub(r'\s+', ' ', clean_query).strip() + + return clean_query + + async def _get_default_suggestions(self, user_id: int, context: SearchContext, limit: int) -> List[SearchSuggestion]: + """Get default suggestions when query is too short""" + suggestions = [] + + try: + behavior = await self._get_user_behavior(user_id) + + # Add favorite artists + for artist in behavior.favorite_artists[:limit // 3]: + suggestion = SearchSuggestion( + id=f"artist_{artist}", + type=SuggestionType.ARTIST, + title=artist, + subtitle="Favorite Artist", + image_url=None, + url=f"/search?q={artist}", + metadata={'source': 'favorites'}, + relevance_score=0.8, + context=context, + created_at=datetime.datetime.utcnow() + ) + suggestions.append(suggestion) + + # Add trending content + if context == SearchContext.DISCOVERY: + trending = await self._get_trending_suggestions(user_id, limit // 3) + suggestions.extend(trending) + + # Add popular genres + for genre in behavior.favorite_genres[:limit // 3]: + suggestion = SearchSuggestion( + id=f"genre_{genre}", + type=SuggestionType.GENRE, + title=genre.title(), + subtitle="Popular Genre", + image_url=None, + url=f"/search?genre={genre}", + metadata={'source': 'favorites'}, + relevance_score=0.7, + context=context, + created_at=datetime.datetime.utcnow() + ) + suggestions.append(suggestion) + + except Exception as e: + logger.error(f"Error getting default suggestions: {e}") + + return suggestions[:limit] + + async def _get_local_track_suggestions(self, user_id: int, query: str, limit: int) -> List[SearchSuggestion]: + """Get track suggestions from local library""" + suggestions = [] + + try: + with Session(db.engine) as session: + # Search tracks in local library + search_pattern = f"%{query}%" + tracks_query = select(Track).where( + or_( + Track.title.ilike(search_pattern), + Track.artist.ilike(search_pattern), + Track.album.ilike(search_pattern) + ) + ).limit(limit) + + tracks = session.execute(tracks_query).scalars().all() + + for track in tracks: + suggestion = SearchSuggestion( + id=f"track_{track.id}", + type=SuggestionType.TRACK, + title=track.title, + subtitle=f"{track.artist} • {track.album}", + image_url=track.image, + url=f"/track/{track.id}", + metadata={ + 'track_id': track.id, + 'artist': track.artist, + 'album': track.album, + 'duration': track.duration, + 'play_count': track.playcount + }, + relevance_score=self._calculate_local_relevance(track, query), + context=SearchContext.GENERAL, + created_at=datetime.datetime.utcnow() + ) + suggestions.append(suggestion) + + except Exception as e: + logger.error(f"Error getting local track suggestions: {e}") + + return suggestions + + async def _get_artist_suggestions(self, user_id: int, query: str, limit: int) -> List[SearchSuggestion]: + """Get artist suggestions""" + suggestions = [] + + try: + with Session(db.engine) as session: + # Search artists + search_pattern = f"%{query}%" + artists_query = select(Artist).where( + Artist.name.ilike(search_pattern) + ).limit(limit) + + artists = session.execute(artists_query).scalars().all() + + for artist in artists: + suggestion = SearchSuggestion( + id=f"artist_{artist.id}", + type=SuggestionType.ARTIST, + title=artist.name, + subtitle=f"{artist.trackcount} tracks", + image_url=artist.image, + url=f"/artist/{artist.id}", + metadata={ + 'artist_id': artist.id, + 'track_count': artist.trackcount, + 'album_count': artist.albumcount + }, + relevance_score=self._calculate_artist_relevance(artist, query), + context=SearchContext.GENERAL, + created_at=datetime.datetime.utcnow() + ) + suggestions.append(suggestion) + + except Exception as e: + logger.error(f"Error getting artist suggestions: {e}") + + return suggestions + + async def _get_album_suggestions(self, user_id: int, query: str, limit: int) -> List[SearchSuggestion]: + """Get album suggestions""" + suggestions = [] + + try: + # This would search albums in local library + # For now, return empty list as placeholder + pass + + except Exception as e: + logger.error(f"Error getting album suggestions: {e}") + + return suggestions + + async def _get_global_suggestions(self, user_id: int, query: str, limit: int) -> List[SearchSuggestion]: + """Get suggestions from global catalog""" + suggestions = [] + + try: + # Search global catalog + catalog_results = await music_catalog_service.search_global_catalog(query, "all", limit) + + # Convert tracks to suggestions + for track in catalog_results.tracks[:limit // 2]: + suggestion = SearchSuggestion( + id=f"global_track_{track.spotify_id}", + type=SuggestionType.TRACK, + title=track.title, + subtitle=f"{track.artist} • {track.album}", + image_url=track.image_url, + url=f"/catalog/track/{track.spotify_id}", + metadata={ + 'spotify_id': track.spotify_id, + 'source': 'global_catalog', + 'popularity': track.popularity + }, + relevance_score=self._calculate_global_relevance(track, query, user_id), + context=SearchContext.DISCOVERY, + created_at=datetime.datetime.utcnow() + ) + suggestions.append(suggestion) + + # Convert artists to suggestions + for artist in catalog_results.artists[:limit // 2]: + suggestion = SearchSuggestion( + id=f"global_artist_{artist.spotify_id}", + type=SuggestionType.ARTIST, + title=artist.title, + subtitle="Discover on Spotify", + image_url=artist.image_url, + url=f"/catalog/artist/{artist.spotify_id}", + metadata={ + 'spotify_id': artist.spotify_id, + 'source': 'global_catalog', + 'popularity': artist.popularity + }, + relevance_score=self._calculate_global_relevance(artist, query, user_id), + context=SearchContext.DISCOVERY, + created_at=datetime.datetime.utcnow() + ) + suggestions.append(suggestion) + + except Exception as e: + logger.error(f"Error getting global suggestions: {e}") + + return suggestions + + async def _get_behavior_suggestions(self, user_id: int, query: str, context: SearchContext, limit: int) -> List[SearchSuggestion]: + """Get behavior-based suggestions""" + suggestions = [] + + try: + behavior = await self._get_user_behavior(user_id) + + # Suggest based on favorite genres + for genre in behavior.favorite_genres[:limit // 3]: + if query.lower() in genre.lower(): + suggestion = SearchSuggestion( + id=f"genre_suggestion_{genre}", + type=SuggestionType.GENRE, + title=genre.title(), + subtitle="Based on your preferences", + image_url=None, + url=f"/search?genre={genre}", + metadata={'source': 'behavior'}, + relevance_score=0.9, + context=context, + created_at=datetime.datetime.utcnow() + ) + suggestions.append(suggestion) + + # Suggest based on listening patterns + if 'search' in behavior.listening_patterns: + recent_searches = behavior.listening_patterns['search'][-5:] + for recent_search in recent_searches: + if query.lower() in recent_search.get('query', '').lower(): + suggestion = SearchSuggestion( + id=f"recent_search_{recent_search.get('query', '')}", + type=SuggestionType.SEARCH_QUERY, + title=recent_search.get('query', ''), + subtitle="Recent search", + image_url=None, + url=f"/search?q={recent_search.get('query', '')}", + metadata={'source': 'recent_searches'}, + relevance_score=0.8, + context=context, + created_at=datetime.datetime.utcnow() + ) + suggestions.append(suggestion) + + except Exception as e: + logger.error(f"Error getting behavior suggestions: {e}") + + return suggestions + + def _calculate_local_relevance(self, track: Track, query: str) -> float: + """Calculate relevance score for local track""" + score = 0.0 + + query_lower = query.lower() + + # Title match + if track.title and query_lower in track.title.lower(): + score += 0.8 + + # Artist match + if track.artist and query_lower in track.artist.lower(): + score += 0.6 + + # Album match + if track.album and query_lower in track.album.lower(): + score += 0.4 + + # Play count boost + if track.playcount > 0: + score += min(track.playcount / 100, 0.3) + + return min(score, 1.0) + + def _calculate_artist_relevance(self, artist: Artist, query: str) -> float: + """Calculate relevance score for artist""" + score = 0.0 + + query_lower = query.lower() + + # Name match + if artist.name and query_lower in artist.name.lower(): + score += 0.9 + + # Track count boost + if artist.trackcount > 0: + score += min(artist.trackcount / 50, 0.3) + + return min(score, 1.0) + + def _calculate_global_relevance(self, item: Any, query: str, user_id: int) -> float: + """Calculate relevance score for global catalog item""" + score = 0.0 + + query_lower = query.lower() + + # Title/name match + if hasattr(item, 'title') and item.title and query_lower in item.title.lower(): + score += 0.7 + + # Artist match + if hasattr(item, 'artist') and item.artist and query_lower in item.artist.lower(): + score += 0.5 + + # Popularity boost + if hasattr(item, 'popularity') and item.popularity: + score += min(item.popularity / 100, 0.3) + + return min(score, 1.0) + + def _calculate_download_relevance(self, item: Any, user_id: int) -> float: + """Calculate relevance score for download suggestions""" + score = 0.0 + + # Base relevance from popularity + if hasattr(item, 'popularity') and item.popularity: + score += min(item.popularity / 100, 0.5) + + # User behavior boost + if user_id in self.user_behaviors: + behavior = self.user_behaviors[user_id] + + # Favorite artist boost + if hasattr(item, 'artist') and item.artist in behavior.favorite_artists: + score += 0.3 + + # Favorite genre boost + # This would require genre information from the item + + return min(score, 1.0) + + async def _get_user_behavior(self, user_id: int) -> UserBehavior: + """Get or create user behavior data""" + if user_id in self.user_behaviors: + return self.user_behaviors[user_id] + + # Create default behavior + behavior = UserBehavior( + user_id=user_id, + favorite_genres=[], + favorite_artists=[], + listening_patterns={}, + search_history=[], + download_preferences={}, + interaction_patterns={}, + last_updated=datetime.datetime.utcnow() + ) + + self.user_behaviors[user_id] = behavior + return behavior + + async def _log_search_query(self, user_id: int, query: str, context: SearchContext, suggestions: List[SearchSuggestion]): + """Log search query for learning""" + try: + search_data = { + 'query': query, + 'context': context.value, + 'suggestion_count': len(suggestions), + 'timestamp': datetime.datetime.utcnow().isoformat(), + 'suggestions': [ + { + 'id': s.id, + 'type': s.type.value, + 'title': s.title, + 'relevance_score': s.relevance_score + } + for s in suggestions[:5] # Log top 5 suggestions + ] + } + + # Add to search history + behavior = await self._get_user_behavior(user_id) + behavior.search_history.append(search_data) + + # Limit history size + if len(behavior.search_history) > self.search_history_limit: + behavior.search_history = behavior.search_history[-self.search_history_limit:] + + except Exception as e: + logger.error(f"Error logging search query: {e}") + + async def _get_track_info(self, track_id: str) -> Optional[Dict[str, Any]]: + """Get track information""" + try: + with Session(db.engine) as session: + track = session.get(Track, track_id) + if not track: + return None + + return { + 'id': track.id, + 'title': track.title, + 'artist': track.artist, + 'album': track.album, + 'duration': track.duration, + 'playcount': track.playcount, + 'image': track.image + } + except Exception as e: + logger.error(f"Error getting track info: {e}") + return None + + async def _get_popular_genres(self, user_id: int) -> List[str]: + """Get popular genres for user""" + try: + with Session(db.engine) as session: + # This would query genre data from tracks + # For now, return common genres + return ['rock', 'pop', 'electronic', 'jazz', 'classical', 'hip-hop', 'country', 'blues'] + except Exception as e: + logger.error(f"Error getting popular genres: {e}") + return [] + + async def _get_genre_track_count(self, genre: str) -> int: + """Get track count for genre""" + try: + with Session(db.engine) as session: + # This would count tracks by genre + # For now, return placeholder + return 100 + except Exception as e: + logger.error(f"Error getting genre track count: {e}") + return 0 + + async def _get_year_track_count(self, year: int) -> int: + """Get track count for year""" + try: + with Session(db.engine) as session: + # This would count tracks by year + # For now, return placeholder + return 50 + except Exception as e: + logger.error(f"Error getting year track count: {e}") + return 0 + + async def _get_trending_suggestions(self, user_id: int, limit: int) -> List[SearchSuggestion]: + """Get trending suggestions""" + # This would implement trending logic + return [] + + async def _get_trending_download_suggestions(self, user_id: int, limit: int) -> List[SearchSuggestion]: + """Get trending download suggestions""" + # This would implement trending download logic + return [] + + async def _get_track_recommendations(self, user_id: int, behavior: UserBehavior, limit: int) -> List[SearchSuggestion]: + """Get track recommendations based on behavior""" + # This would use AI recommender + return [] + + async def _get_artist_recommendations(self, user_id: int, behavior: UserBehavior, limit: int) -> List[SearchSuggestion]: + """Get artist recommendations based on behavior""" + # This would use AI recommender + return [] + + async def _get_album_recommendations(self, user_id: int, behavior: UserBehavior, limit: int) -> List[SearchSuggestion]: + """Get album recommendations based on behavior""" + # This would use AI recommender + return [] + + async def _get_similar_track_suggestions(self, user_id: int, current_track: Dict[str, Any], limit: int) -> List[SearchSuggestion]: + """Get similar track suggestions""" + # This would implement similarity logic + return [] + + async def _get_same_artist_suggestions(self, user_id: int, artist_name: str, limit: int) -> List[SearchSuggestion]: + """Get suggestions from same artist""" + # This would query tracks by artist + return [] + + async def _get_same_genre_suggestions(self, user_id: int, current_track: Dict[str, Any], limit: int) -> List[SearchSuggestion]: + """Get suggestions from same genre""" + # This would query tracks by genre + return [] + + async def _get_popular_suggestions(self, user_id: int, limit: int) -> List[SearchSuggestion]: + """Get popular suggestions""" + # This would implement popularity logic + return [] + + async def _update_search_behavior(self, behavior: UserBehavior, interaction_data: Dict[str, Any]): + """Update search behavior""" + # This would update search patterns + pass + + async def _update_play_behavior(self, behavior: UserBehavior, interaction_data: Dict[str, Any]): + """Update play behavior""" + # This would update listening patterns + pass + + async def _update_download_behavior(self, behavior: UserBehavior, interaction_data: Dict[str, Any]): + """Update download behavior""" + # This would update download preferences + pass + + async def _update_like_behavior(self, behavior: UserBehavior, interaction_data: Dict[str, Any]): + """Update like behavior""" + # This would update favorites + pass + + async def _save_user_behavior(self, behavior: UserBehavior): + """Save user behavior to database""" + # This would save to database + pass + + +# Global service instance +advanced_ux_service = AdvancedUXService() diff --git a/src/swingmusic/services/audio_quality_manager.py b/src/swingmusic/services/audio_quality_manager.py new file mode 100644 index 00000000..0d485c88 --- /dev/null +++ b/src/swingmusic/services/audio_quality_manager.py @@ -0,0 +1,928 @@ +""" +Advanced Audio Quality Management Service + +This service provides comprehensive audio quality control including: +- Adaptive quality streaming based on network conditions +- Multi-format support with intelligent transcoding +- Audio enhancement features (EQ, spatial audio, loudness normalization) +- Quality comparison and analysis tools +- Device-specific optimization +""" + +import asyncio +import logging +import json +import os +import subprocess +import tempfile +from typing import Dict, List, Optional, Tuple, Any, Union +from dataclasses import dataclass, asdict +from enum import Enum +from pathlib import Path + +import aiofiles +from sqlalchemy.orm import Session + +from swingmusic.db import db +from swingmusic.config import USER_DATA_DIR +from swingmusic.utils.network_monitor import NetworkMonitor +from swingmusic.utils.device_detector import DeviceDetector + +logger = logging.getLogger(__name__) + + +class AudioFormat(Enum): + """Supported audio formats""" + FLAC = "flac" + ALAC = "alac" + WAV = "wav" + AIFF = "aiff" + MP3_320 = "mp3_320" + MP3_256 = "mp3_256" + MP3_192 = "mp3_192" + MP3_128 = "mp3_128" + AAC_256 = "aac_256" + AAC_192 = "aac_192" + AAC_128 = "aac_128" + OGG_VORBIS = "ogg_vorbis" + OGG_OPUS = "ogg_opus" + + +class QualityLevel(Enum): + """Audio quality levels""" + LOSSLESS = "lossless" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + DATA_SAVER = "data_saver" + + +class SampleRate(Enum): + """Supported sample rates""" + RATE_44_1 = "44.1kHz" + RATE_48 = "48kHz" + RATE_96 = "96kHz" + RATE_192 = "192kHz" + + +class BitDepth(Enum): + """Supported bit depths""" + BIT_16 = "16bit" + BIT_24 = "24bit" + BIT_32 = "32bit" + + +class SpatialAudioFormat(Enum): + """Spatial audio formats""" + NONE = "none" + STEREO = "stereo" + BINAURAL = "binaural" + DOLBY_ATMOS = "dolby_atmos" + SONY_360 = "sony_360" + AMBISONIC = "ambisonic" + + +@dataclass +class AudioQualitySettings: + """Comprehensive audio quality settings""" + # Streaming quality + streaming_quality: QualityLevel = QualityLevel.HIGH + adaptive_quality: bool = True + network_aware_quality: bool = True + device_specific_quality: bool = True + + # Download quality + download_format: AudioFormat = AudioFormat.FLAC + download_bitrate: Optional[int] = None # For lossy formats + download_sample_rate: SampleRate = SampleRate.RATE_44_1 + download_bit_depth: BitDepth = BitDepth.BIT_16 + + # Advanced audio settings + enable_dolby_atmos: bool = False + enable_360_audio: bool = False + spatial_audio_format: SpatialAudioFormat = SpatialAudioFormat.STEREO + + # Audio enhancements + enable_adaptive_eq: bool = True + enable_spatial_audio_processing: bool = False + enable_loudness_normalization: bool = True + target_loudness: float = -14.0 # LUFS + + # Processing settings + enable_crossfade: bool = False + crossfade_duration: float = 2.0 + enable_gapless_playback: bool = True + enable_replaygain: bool = True + + # Quality preferences + prioritize_fidelity: bool = True + prioritize_file_size: bool = False + prioritize_compatibility: bool = False + + # Advanced options + custom_ffmpeg_params: Dict[str, Any] = None + enable_experimental_codecs: bool = False + cache_transcoded_files: bool = True + + +@dataclass +class AudioAnalysis: + """Audio analysis results""" + file_path: str + format: str + duration: float + sample_rate: int + bit_depth: int + bitrate: int + channels: int + codec: str + + # Audio characteristics + dynamic_range: float # dB + peak_level: float # dB + rms_level: float # dB + loudness: float # LUFS + + # Frequency analysis + frequency_response: Dict[str, float] + spectral_centroid: float + spectral_rolloff: float + + # Quality metrics + signal_to_noise_ratio: float + total_harmonic_distortion: float + + # Metadata + detected_genre: Optional[str] = None + acoustic_features: Dict[str, float] = None + + +@dataclass +class QualityComparison: + """Quality comparison between different formats""" + original_file: str + formats: Dict[str, Dict[str, Any]] + + # Comparison metrics + size_difference: Dict[str, float] # Percentage + quality_score: Dict[str, float] # 0-100 + transparency_score: Dict[str, float] # 0-100 + + # Recommendations + recommended_format: str + recommended_reason: str + + +class AudioTranscoder: + """Audio transcoding with FFmpeg""" + + def __init__(self): + self.ffmpeg_path = self._find_ffmpeg() + self.temp_dir = Path(tempfile.gettempdir()) / "swingmusic_transcode" + self.temp_dir.mkdir(exist_ok=True) + + def _find_ffmpeg(self) -> str: + """Find FFmpeg executable""" + # Try common paths + ffmpeg_paths = [ + "ffmpeg", + "/usr/bin/ffmpeg", + "/usr/local/bin/ffmpeg", + "/opt/homebrew/bin/ffmpeg" + ] + + for path in ffmpeg_paths: + try: + result = subprocess.run([path, "-version"], + capture_output=True, text=True) + if result.returncode == 0: + return path + except (subprocess.SubprocessError, FileNotFoundError): + continue + + raise RuntimeError("FFmpeg not found. Please install FFmpeg.") + + async def transcode(self, input_path: str, output_path: str, + settings: AudioQualitySettings) -> bool: + """Transcode audio file according to settings""" + try: + # Build FFmpeg command + cmd = self._build_transcode_command(input_path, output_path, settings) + + # Execute transcoding + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + logger.error(f"FFmpeg error: {stderr.decode()}") + return False + + return True + + except Exception as e: + logger.error(f"Transcoding error: {e}") + return False + + def _build_transcode_command(self, input_path: str, output_path: str, + settings: AudioQualitySettings) -> List[str]: + """Build FFmpeg command for transcoding""" + cmd = [self.ffmpeg_path, "-i", input_path] + + # Audio codec settings + if settings.download_format == AudioFormat.FLAC: + cmd.extend(["-c:a", "flac", "-compression_level", "8"]) + elif settings.download_format == AudioFormat.MP3_320: + cmd.extend(["-c:a", "libmp3lame", "-b:a", "320k"]) + elif settings.download_format == AudioFormat.MP3_256: + cmd.extend(["-c:a", "libmp3lame", "-b:a", "256k"]) + elif settings.download_format == AudioFormat.AAC_256: + cmd.extend(["-c:a", "aac", "-b:a", "256k"]) + elif settings.download_format == AudioFormat.OGG_VORBIS: + cmd.extend(["-c:a", "libvorbis", "-b:a", "256k"]) + else: + # Default to FLAC + cmd.extend(["-c:a", "flac"]) + + # Sample rate + if settings.download_sample_rate == SampleRate.RATE_48: + cmd.extend(["-ar", "48000"]) + elif settings.download_sample_rate == SampleRate.RATE_96: + cmd.extend(["-ar", "96000"]) + elif settings.download_sample_rate == SampleRate.RATE_192: + cmd.extend(["-ar", "192000"]) + + # Bit depth + if settings.download_bit_depth == BitDepth.BIT_24: + cmd.extend(["-sample_format", "s24"]) + elif settings.download_bit_depth == BitDepth.BIT_32: + cmd.extend(["-sample_format", "s32"]) + + # Custom FFmpeg parameters + if settings.custom_ffmpeg_params: + for key, value in settings.custom_ffmpeg_params.items(): + if isinstance(value, bool): + if value: + cmd.extend([key]) + else: + cmd.extend([key, str(value)]) + + # Output settings + cmd.extend(["-y", output_path]) # -y to overwrite + + return cmd + + +class AudioAnalyzer: + """Audio analysis using FFmpeg and audio processing libraries""" + + def __init__(self): + self.ffmpeg_path = self._find_ffmpeg() + + def _find_ffmpeg(self) -> str: + """Find FFmpeg executable""" + ffmpeg_paths = [ + "ffmpeg", + "/usr/bin/ffmpeg", + "/usr/local/bin/ffmpeg", + "/opt/homebrew/bin/ffmpeg" + ] + + for path in ffmpeg_paths: + try: + result = subprocess.run([path, "-version"], + capture_output=True, text=True) + if result.returncode == 0: + return path + except (subprocess.SubprocessError, FileNotFoundError): + continue + + raise RuntimeError("FFmpeg not found") + + async def analyze_file(self, file_path: str) -> AudioAnalysis: + """Comprehensive audio file analysis""" + try: + # Get basic info with FFprobe + probe_cmd = [ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", "-show_streams", file_path + ] + + process = await asyncio.create_subprocess_exec( + *probe_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + raise RuntimeError(f"FFprobe error: {stderr.decode()}") + + probe_data = json.loads(stdout.decode()) + + # Extract audio stream info + audio_stream = None + for stream in probe_data.get("streams", []): + if stream.get("codec_type") == "audio": + audio_stream = stream + break + + if not audio_stream: + raise ValueError("No audio stream found") + + format_info = probe_data.get("format", {}) + + # Create analysis object + analysis = AudioAnalysis( + file_path=file_path, + format=format_info.get("format_name", "unknown"), + duration=float(format_info.get("duration", 0)), + sample_rate=int(audio_stream.get("sample_rate", 44100)), + bit_depth=self._extract_bit_depth(audio_stream), + bitrate=int(format_info.get("bit_rate", 0)), + channels=int(audio_stream.get("channels", 2)), + codec=audio_stream.get("codec_name", "unknown"), + + # Audio characteristics (simplified for now) + dynamic_range=0.0, + peak_level=0.0, + rms_level=0.0, + loudness=0.0, + frequency_response={}, + spectral_centroid=0.0, + spectral_rolloff=0.0, + signal_to_noise_ratio=0.0, + total_harmonic_distortion=0.0 + ) + + # Perform advanced analysis + await self._perform_advanced_analysis(analysis) + + return analysis + + except Exception as e: + logger.error(f"Audio analysis error: {e}") + raise + + def _extract_bit_depth(self, stream: Dict) -> int: + """Extract bit depth from stream info""" + bits_per_sample = stream.get("bits_per_sample") + if bits_per_sample: + return int(bits_per_sample) + + # Try to determine from codec + codec_name = stream.get("codec_name", "").lower() + if "flac" in codec_name or "pcm" in codec_name: + return 16 # Default assumption + return 16 + + async def _perform_advanced_analysis(self, analysis: AudioAnalysis): + """Perform advanced audio analysis""" + try: + # This would integrate with audio processing libraries + # For now, we'll provide placeholder values + + # In a real implementation, you would use: + # - librosa for audio analysis + # - pydub for basic processing + # - numpy for mathematical operations + + analysis.dynamic_range = 15.0 # Placeholder + analysis.peak_level = -1.0 # Placeholder + analysis.rms_level = -12.0 # Placeholder + analysis.loudness = -14.0 # Placeholder (LUFS target) + + # Frequency bands (Hz) + analysis.frequency_response = { + "20": 0.0, + "60": 0.0, + "250": 0.0, + "1000": 0.0, + "4000": 0.0, + "16000": 0.0, + "20000": 0.0 + } + + analysis.spectral_centroid = 2000.0 + analysis.spectral_rolloff = 18000.0 + analysis.signal_to_noise_ratio = 60.0 + analysis.total_harmonic_distortion = 0.01 + + except Exception as e: + logger.error(f"Advanced analysis error: {e}") + + +class AdaptiveQualityManager: + """Adaptive quality management based on conditions""" + + def __init__(self): + self.network_monitor = NetworkMonitor() + self.device_detector = DeviceDetector() + self.quality_profiles = self._load_quality_profiles() + + def _load_quality_profiles(self) -> Dict[str, Dict]: + """Load quality profiles for different conditions""" + return { + "excellent_network": { + "streaming": QualityLevel.LOSSLESS, + "download": AudioFormat.FLAC, + "bitrate": None + }, + "good_network": { + "streaming": QualityLevel.HIGH, + "download": AudioFormat.MP3_320, + "bitrate": 320 + }, + "fair_network": { + "streaming": QualityLevel.MEDIUM, + "download": AudioFormat.MP3_256, + "bitrate": 256 + }, + "poor_network": { + "streaming": QualityLevel.LOW, + "download": AudioFormat.MP3_128, + "bitrate": 128 + }, + "data_saver": { + "streaming": QualityLevel.DATA_SAVER, + "download": AudioFormat.MP3_128, + "bitrate": 128 + }, + "mobile_device": { + "streaming": QualityLevel.MEDIUM, + "download": AudioFormat.AAC_256, + "bitrate": 256 + }, + "high_end_device": { + "streaming": QualityLevel.LOSSLESS, + "download": AudioFormat.FLAC, + "bitrate": None + }, + "battery_saver": { + "streaming": QualityLevel.LOW, + "download": AudioFormat.MP3_192, + "bitrate": 192 + } + } + + async def get_optimal_quality(self, user_settings: AudioQualitySettings, + context: Dict[str, Any] = None) -> Dict[str, Any]: + """Get optimal quality settings based on current conditions""" + context = context or {} + + # Get current conditions + network_speed = await self.network_monitor.get_current_speed() + device_info = self.device_detector.get_device_info() + battery_level = device_info.get("battery_level", 100) + + # Determine quality profile + profile = self._determine_quality_profile( + network_speed, device_info, battery_level, user_settings, context + ) + + return profile + + def _determine_quality_profile(self, network_speed: float, device_info: Dict, + battery_level: float, user_settings: AudioQualitySettings, + context: Dict) -> Dict[str, Any]: + """Determine the best quality profile""" + + # Network-based selection + if user_settings.network_aware_quality: + if network_speed > 10.0: # Mbps + network_profile = "excellent_network" + elif network_speed > 5.0: + network_profile = "good_network" + elif network_speed > 2.0: + network_profile = "fair_network" + elif network_speed > 0.5: + network_profile = "poor_network" + else: + network_profile = "data_saver" + else: + network_profile = "good_network" # Default + + # Device-based selection + if user_settings.device_specific_quality: + device_type = device_info.get("type", "desktop") + if device_type == "mobile": + device_profile = "mobile_device" + elif device_type == "high_end": + device_profile = "high_end_device" + else: + device_profile = "good_network" + else: + device_profile = "good_network" + + # Battery-based selection + if battery_level < 20 and context.get("battery_saver", False): + battery_profile = "battery_saver" + else: + battery_profile = "good_network" + + # Select the most restrictive profile + profiles = [network_profile, device_profile, battery_profile] + selected_profile = self.quality_profiles["good_network"] # Default + + for profile_name in profiles: + profile = self.quality_profiles.get(profile_name) + if profile: + # Compare and select the most appropriate + if self._is_more_restrictive(profile, selected_profile): + selected_profile = profile + + return selected_profile.copy() + + def _is_more_restrictive(self, profile1: Dict, profile2: Dict) -> bool: + """Check if profile1 is more restrictive than profile2""" + quality_order = { + QualityLevel.LOSSLESS: 4, + QualityLevel.HIGH: 3, + QualityLevel.MEDIUM: 2, + QualityLevel.LOW: 1, + QualityLevel.DATA_SAVER: 0 + } + + q1 = quality_order.get(profile1.get("streaming"), 2) + q2 = quality_order.get(profile2.get("streaming"), 2) + + return q1 < q2 + + +class AudioEnhancementService: + """Audio enhancement processing""" + + def __init__(self): + self.transcoder = AudioTranscoder() + self.analyzer = AudioAnalyzer() + + async def apply_enhancements(self, input_path: str, output_path: str, + settings: AudioQualitySettings) -> bool: + """Apply audio enhancements to a file""" + try: + # Analyze the input file + analysis = await self.analyzer.analyze_file(input_path) + + # Build enhancement command + cmd = self._build_enhancement_command(input_path, output_path, + settings, analysis) + + # Apply enhancements + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + logger.error(f"Enhancement error: {stderr.decode()}") + return False + + return True + + except Exception as e: + logger.error(f"Audio enhancement error: {e}") + return False + + def _build_enhancement_command(self, input_path: str, output_path: str, + settings: AudioQualitySettings, + analysis: AudioAnalysis) -> List[str]: + """Build FFmpeg command for audio enhancements""" + cmd = [self.transcoder.ffmpeg_path, "-i", input_path] + + # Audio filters + filters = [] + + # Loudness normalization + if settings.enable_loudness_normalization: + filters.append(f"loudnorm=I={settings.target_loudness}") + + # Adaptive EQ (simplified) + if settings.enable_adaptive_eq: + # This would be more sophisticated in a real implementation + # analyzing the frequency response and applying appropriate EQ + filters.append("equalizer=f=1000:width_type=h:width=100:g=2") + + # Spatial audio processing + if settings.enable_spatial_audio_processing: + if settings.spatial_audio_format == SpatialAudioFormat.BINAURAL: + filters.append("bs2b") + elif settings.spatial_audio_format == SpatialAudioFormat.AMBISONIC: + filters.append("surround") + + # Combine filters + if filters: + filter_string = ",".join(filters) + cmd.extend(["-af", filter_string]) + + # Output codec (preserve quality) + cmd.extend(["-c:a", "pcm_s16le"]) + + # Output + cmd.extend(["-y", output_path]) + + return cmd + + +class AudioQualityManager: + """ + Main audio quality management service + + This service coordinates all audio quality operations including: + - Adaptive quality streaming + - Audio transcoding and enhancement + - Quality analysis and comparison + - User preference management + """ + + def __init__(self): + self.transcoder = AudioTranscoder() + self.analyzer = AudioAnalyzer() + self.adaptive_manager = AdaptiveQualityManager() + self.enhancement_service = AudioEnhancementService() + + # Cache for analysis results + self._analysis_cache = {} + self._quality_cache = {} + + async def get_optimal_streaming_quality(self, user_id: int, + context: Dict[str, Any] = None) -> Dict[str, Any]: + """Get optimal streaming quality for user""" + try: + # Get user settings + user_settings = await self._get_user_settings(user_id) + + # Get optimal quality based on conditions + optimal = await self.adaptive_manager.get_optimal_quality( + user_settings, context + ) + + return optimal + + except Exception as e: + logger.error(f"Error getting optimal quality: {e}") + return {"streaming": "medium", "download": "mp3_256", "bitrate": 256} + + async def transcode_for_streaming(self, input_path: str, user_id: int, + context: Dict[str, Any] = None) -> Optional[str]: + """Transcode file for optimal streaming""" + try: + # Get optimal quality + quality_settings = await self.get_optimal_streaming_quality(user_id, context) + + # Create output path + output_dir = Path(USER_DATA_DIR) / "transcoded" + output_dir.mkdir(exist_ok=True) + + input_file = Path(input_path) + output_file = output_dir / f"{input_file.stem}_transcoded.mp3" + + # Build settings for transcoding + settings = AudioQualitySettings() + if quality_settings.get("download") == AudioFormat.FLAC: + settings.download_format = AudioFormat.FLAC + elif quality_settings.get("download") == AudioFormat.MP3_320: + settings.download_format = AudioFormat.MP3_320 + else: + settings.download_format = AudioFormat.MP3_256 + + # Transcode + success = await self.transcoder.transcode( + str(input_file), str(output_file), settings + ) + + if success: + return str(output_file) + else: + return None + + except Exception as e: + logger.error(f"Transcoding error: {e}") + return None + + async def analyze_audio_file(self, file_path: str) -> AudioAnalysis: + """Analyze audio file""" + # Check cache first + if file_path in self._analysis_cache: + return self._analysis_cache[file_path] + + try: + analysis = await self.analyzer.analyze_file(file_path) + self._analysis_cache[file_path] = analysis + return analysis + + except Exception as e: + logger.error(f"Analysis error: {e}") + raise + + async def compare_quality_formats(self, original_path: str, + formats: List[AudioFormat]) -> QualityComparison: + """Compare quality across different formats""" + try: + original_analysis = await self.analyze_audio_file(original_path) + + comparison = QualityComparison( + original_file=original_path, + formats={}, + size_difference={}, + quality_score={}, + transparency_score={}, + recommended_format="flac", + recommended_reason="Best quality for archival" + ) + + original_size = Path(original_path).stat().st_size + + for format_type in formats: + try: + # Transcode to format + temp_file = await self._transcode_to_format(original_path, format_type) + + if temp_file: + # Analyze transcoded file + transcoded_analysis = await self.analyze_audio_file(temp_file) + + # Calculate metrics + transcoded_size = Path(temp_file).stat().st_size + size_diff = ((transcoded_size - original_size) / original_size) * 100 + + quality_score = self._calculate_quality_score( + original_analysis, transcoded_analysis + ) + + transparency_score = self._calculate_transparency_score( + original_analysis, transcoded_analysis + ) + + comparison.formats[format_type.value] = { + "analysis": asdict(transcoded_analysis), + "file_size": transcoded_size, + "file_path": temp_file + } + + comparison.size_difference[format_type.value] = size_diff + comparison.quality_score[format_type.value] = quality_score + comparison.transparency_score[format_type.value] = transparency_score + + # Clean up temp file + os.unlink(temp_file) + + except Exception as e: + logger.error(f"Error comparing format {format_type}: {e}") + continue + + # Determine recommendation + comparison.recommended_format, comparison.recommended_reason = \ + self._determine_best_format(comparison) + + return comparison + + except Exception as e: + logger.error(f"Quality comparison error: {e}") + raise + + async def _transcode_to_format(self, input_path: str, + format_type: AudioFormat) -> Optional[str]: + """Transcode file to specific format for comparison""" + try: + temp_dir = Path(tempfile.gettempdir()) / "swingmusic_compare" + temp_dir.mkdir(exist_ok=True) + + input_file = Path(input_path) + output_file = temp_dir / f"{input_file.stem}_compare.{format_type.value}" + + settings = AudioQualitySettings() + settings.download_format = format_type + + success = await self.transcoder.transcode( + str(input_file), str(output_file), settings + ) + + if success: + return str(output_file) + else: + return None + + except Exception as e: + logger.error(f"Format transcoding error: {e}") + return None + + def _calculate_quality_score(self, original: AudioAnalysis, + transcoded: AudioAnalysis) -> float: + """Calculate quality score (0-100)""" + try: + # Simplified quality calculation + # In a real implementation, this would be more sophisticated + + score = 100.0 + + # Penalize quality loss + if transcoded.bitrate < original.bitrate: + score -= (original.bitrate - transcoded.bitrate) / original.bitrate * 30 + + # Penalize sample rate reduction + if transcoded.sample_rate < original.sample_rate: + score -= (original.sample_rate - transcoded.sample_rate) / original.sample_rate * 20 + + # Penalize bit depth reduction + if transcoded.bit_depth < original.bit_depth: + score -= (original.bit_depth - transcoded.bit_depth) / original.bit_depth * 10 + + return max(0, min(100, score)) + + except Exception: + return 50.0 # Default score + + def _calculate_transparency_score(self, original: AudioAnalysis, + transcoded: AudioAnalysis) -> float: + """Calculate transparency score (0-100)""" + try: + # Simplified transparency calculation + # In a real implementation, this would use ABX testing or perceptual models + + if transcoded.format == original.format: + return 100.0 + + # Lossless formats get high transparency + if transcoded.format in ["flac", "alac", "wav"]: + return 95.0 + + # High bitrate lossy formats + if transcoded.bitrate >= 320: + return 85.0 + elif transcoded.bitrate >= 256: + return 75.0 + elif transcoded.bitrate >= 192: + return 60.0 + else: + return 40.0 + + except Exception: + return 50.0 + + def _determine_best_format(self, comparison: QualityComparison) -> Tuple[str, str]: + """Determine the best format recommendation""" + try: + best_format = "flac" + best_reason = "Best quality for archival purposes" + + # Consider user priorities + scores = comparison.quality_score + + if scores: + # Find format with best balance of quality and size + best_score = 0 + for format_name, score in scores.items(): + size_penalty = abs(comparison.size_difference.get(format_name, 0)) / 100 + combined_score = score - size_penalty * 10 + + if combined_score > best_score: + best_score = combined_score + best_format = format_name + best_reason = f"Best balance of quality ({score:.1f}) and file size" + + return best_format, best_reason + + except Exception: + return "flac", "Best quality for archival purposes" + + async def _get_user_settings(self, user_id: int) -> AudioQualitySettings: + """Get user's audio quality settings""" + try: + with db.session() as session: + # This would query user_settings table + # For now, return defaults + return AudioQualitySettings() + + except Exception as e: + logger.error(f"Error getting user settings: {e}") + return AudioQualitySettings() + + async def update_user_settings(self, user_id: int, + settings: AudioQualitySettings) -> bool: + """Update user's audio quality settings""" + try: + with db.session() as session: + # This would update user_settings table + logger.info(f"Updated audio settings for user {user_id}") + return True + + except Exception as e: + logger.error(f"Error updating user settings: {e}") + return False + + def clear_cache(self): + """Clear analysis and quality cache""" + self._analysis_cache.clear() + self._quality_cache.clear() + + +# Singleton instance +audio_quality_manager = AudioQualityManager() diff --git a/src/swingmusic/services/enhanced_album_grouper.py b/src/swingmusic/services/enhanced_album_grouper.py new file mode 100644 index 00000000..1407dac6 --- /dev/null +++ b/src/swingmusic/services/enhanced_album_grouper.py @@ -0,0 +1,445 @@ +""" +Enhanced Album Grouper for SwingMusic +Handles proper album grouping with various artists, compilations, and metadata normalization +""" + +import re +import unicodedata +from typing import Dict, List, Optional, Set, Tuple +from dataclasses import dataclass +from difflib import SequenceMatcher +import sqlite3 + +from swingmusic import logger +from swingmusic.db.sqlite.utils import get_db_connection + + +@dataclass +class AlbumGroupingKey: + """Key for album grouping with normalization""" + normalized_artist: str + normalized_album: str + year: Optional[str] + is_compilation: bool + album_type: str # album, single, compilation, etc. + + +@dataclass +class AlbumInfo: + """Enhanced album information""" + album_id: str + title: str + artists: List[str] + primary_artist: str + year: Optional[str] + album_type: str + is_compilation: bool + track_count: int + total_duration: int + image_url: Optional[str] + folder_path: str + grouping_key: str + + +class MetadataNormalizer: + """Normalizes metadata for consistent grouping""" + + # Common variations that should be normalized + ARTIST_VARIATIONS = { + 'various artists': ['various artists', 'va', 'various', 'multiple artists'], + 'soundtrack': ['soundtrack', 'ost', 'original soundtrack', 'original sound track'], + 'various': ['various', 'various artists', 'va'], + } + + # Words to remove for better matching + STOP_WORDS = { + 'the', 'a', 'an', 'and', 'or', 'but', 'for', 'nor', 'so', 'yet', + 'to', 'of', 'in', 'on', 'at', 'by', 'for', 'with', 'about', 'as' + } + + # Patterns to clean up + CLEANUP_PATTERNS = [ + r'\[.*?\]', # Remove brackets and content + r'\(.*?\)', # Remove parentheses and content + r'\{.*?\}', # Remove braces and content + r'<.*?>', # Remove angle brackets and content + r' feat\. .*', # Remove featuring info + r' ft\. .*', # Remove featuring info + r' featuring .*', # Remove featuring info + ] + + @classmethod + def normalize_string(cls, text: str) -> str: + """Normalize string for comparison""" + if not text: + return "" + + # Convert to lowercase and normalize unicode + text = unicodedata.normalize('NFKD', text.lower()) + + # Remove accents and diacritics + text = ''.join(c for c in text if not unicodedata.combining(c)) + + # Apply cleanup patterns + for pattern in cls.CLEANUP_PATTERNS: + text = re.sub(pattern, '', text, flags=re.IGNORECASE) + + # Remove extra whitespace and punctuation + text = re.sub(r'[^\w\s]', ' ', text) + text = re.sub(r'\s+', ' ', text).strip() + + # Remove stop words (optional for album names) + # words = text.split() + # text = ' '.join(word for word in words if word not in cls.STOP_WORDS) + + return text + + @classmethod + def normalize_artist(cls, artist: str) -> str: + """Normalize artist name for grouping""" + if not artist: + return "" + + normalized = cls.normalize_string(artist) + + # Handle common variations + for standard, variations in cls.ARTIST_VARIATIONS.items(): + if normalized in variations: + return standard + + return normalized + + @classmethod + def normalize_album(cls, album: str) -> str: + """Normalize album name for grouping""" + return cls.normalize_string(album) + + @classmethod + def extract_year(cls, date_str: str) -> Optional[str]: + """Extract year from date string""" + if not date_str: + return None + + # Look for 4-digit year patterns + year_match = re.search(r'\b(19|20)\d{2}\b', date_str) + if year_match: + return year_match.group() + + return None + + @classmethod + def is_compilation(cls, artists: List[str], albumartist: str = None) -> bool: + """Determine if album is a compilation""" + if not artists: + return False + + # Check if albumartist is "Various Artists" + if albumartist: + normalized_albumartist = cls.normalize_artist(albumartist) + if normalized_albumartist in ['various artists', 'va', 'various']: + return True + + # Check if there are many different artists + unique_artists = set(cls.normalize_artist(artist) for artist in artists) + + # If more than 3 unique artists, likely a compilation + if len(unique_artists) > 3: + return True + + # Check for common compilation indicators + album_lower = ' '.join(artists).lower() + compilation_indicators = [ + 'various artists', 'soundtrack', 'ost', 'compilation', + 'various', 'multiple artists', 'collection', 'greatest hits' + ] + + return any(indicator in album_lower for indicator in compilation_indicators) + + +class ArtistAliasResolver: + """Resolves artist aliases to canonical names""" + + def __init__(self): + self.aliases: Dict[str, str] = {} + self._load_common_aliases() + + def _load_common_aliases(self): + """Load common artist aliases""" + # Common artist name variations + common_aliases = { + 'taylor swift': ['t. swift', 'taylor', 'swift'], + 'the beatles': ['beatles', 'the fab four'], + 'led zeppelin': ['zeppelin', 'led zep'], + 'pink floyd': ['floyd'], + 'the rolling stones': ['rolling stones', 'stones'], + 'bob dylan': ['dylan', 'bobby dylan'], + 'david bowie': ['bowie', 'ziggy stardust'], + # Add more as needed + } + + for canonical, aliases in common_aliases.items(): + for alias in aliases: + self.aliases[MetadataNormalizer.normalize_string(alias)] = canonical + + def resolve_alias(self, artist: str) -> str: + """Resolve artist alias to canonical name""" + normalized = MetadataNormalizer.normalize_string(artist) + return self.aliases.get(normalized, artist) + + def add_alias(self, canonical: str, alias: str): + """Add a new artist alias""" + normalized_alias = MetadataNormalizer.normalize_string(alias) + self.aliases[normalized_alias] = canonical + + +class AlbumGrouper: + """Enhanced album grouping with proper normalization""" + + def __init__(self): + self.metadata_normalizer = MetadataNormalizer() + self.alias_resolver = ArtistAliasResolver() + self.grouping_cache: Dict[str, AlbumGroupingKey] = {} + + def normalize_album_artist(self, track_metadata: Dict[str, any]) -> str: + """Normalize album artist for proper grouping""" + # Try albumartist first + albumartist = track_metadata.get('albumartist') + if albumartist: + normalized = self.metadata_normalizer.normalize_artist(albumartist) + resolved = self.alias_resolver.resolve_alias(normalized) + return resolved + + # Fall back to artist + artist = track_metadata.get('artist') + if artist: + normalized = self.metadata_normalizer.normalize_artist(artist) + resolved = self.alias_resolver.resolve_alias(normalized) + return resolved + + return "Unknown Artist" + + def create_grouping_key(self, track_metadata: Dict[str, any]) -> AlbumGroupingKey: + """Create consistent grouping key for albums""" + # Extract and normalize artist + artists = self._extract_artists(track_metadata) + primary_artist = self.normalize_album_artist(track_metadata) + + # Normalize album name + album_name = track_metadata.get('album', '') + normalized_album = self.metadata_normalizer.normalize_album(album_name) + + # Extract year + release_date = track_metadata.get('date') or track_metadata.get('year') + year = self.metadata_normalizer.extract_year(str(release_date)) if release_date else None + + # Determine if compilation + is_compilation = self.metadata_normalizer.is_compilation( + artists, track_metadata.get('albumartist') + ) + + # Determine album type + album_type = track_metadata.get('albumtype', 'album') + if is_compilation: + album_type = 'compilation' + + return AlbumGroupingKey( + normalized_artist=primary_artist, + normalized_album=normalized_album, + year=year, + is_compilation=is_compilation, + album_type=album_type + ) + + def create_grouping_key_string(self, track_metadata: Dict[str, any]) -> str: + """Create string-based grouping key for database storage""" + key = self.create_grouping_key(track_metadata) + + # Include year for different editions but allow fallback + year_part = f"::{key.year}" if key.year else "" + + # Mark compilations specially + compilation_part = "::COMP" if key.is_compilation else "" + + return f"{key.normalized_artist}::{key.normalized_album}{year_part}{compilation_part}" + + def _extract_artists(self, track_metadata: Dict[str, any]) -> List[str]: + """Extract list of artists from track metadata""" + artists = [] + + # Try artists field (array) + if 'artists' in track_metadata: + if isinstance(track_metadata['artists'], list): + artists.extend(track_metadata['artists']) + else: + artists.append(str(track_metadata['artists'])) + + # Try artist field + if 'artist' in track_metadata: + artist_str = track_metadata['artist'] + if isinstance(artist_str, list): + artists.extend(artist_str) + else: + # Split common separators + for sep in [',', ';', '&', ' and ', ' ft ', ' feat ']: + if sep in artist_str: + artists.extend([a.strip() for a in artist_str.split(sep)]) + break + else: + artists.append(artist_str) + + # Remove duplicates and empty strings + return list(set(filter(None, artists))) + + def calculate_similarity(self, str1: str, str2: str) -> float: + """Calculate similarity between two strings""" + return SequenceMatcher(None, str1, str2).ratio() + + def should_group_together(self, key1: AlbumGroupingKey, key2: AlbumGroupingKey) -> bool: + """Determine if two albums should be grouped together""" + # Different artists - don't group unless both are compilations + if key1.normalized_artist != key2.normalized_artist: + if not (key1.is_compilation and key2.is_compilation): + return False + + # Check album name similarity + album_similarity = self.calculate_similarity(key1.normalized_album, key2.normalized_album) + if album_similarity < 0.8: # 80% similarity threshold + return False + + # If years are available, they should be close or identical + if key1.year and key2.year: + if key1.year != key2.year: + # Allow grouping if years are close (e.g., reissues) + year_diff = abs(int(key1.year) - int(key2.year)) + if year_diff > 5: # More than 5 years difference + return False + + return True + + def group_albums_from_database(self) -> Dict[str, List[Dict[str, any]]]: + """Group albums from database tracks""" + try: + with get_db_connection() as conn: + # Get all tracks with album information + query = """ + SELECT + t.trackhash, + t.title, + t.artist, + t.albumartist, + t.album, + t.date, + t.year, + t.albumtype, + t.image, + t.folderpath, + t.duration + FROM tracks t + WHERE t.album IS NOT NULL AND t.album != '' + ORDER BY t.albumartist, t.album, t.date, t.tracknumber + """ + + cursor = conn.execute(query) + tracks = cursor.fetchall() + + # Group tracks by album key + album_groups: Dict[str, List[Dict[str, any]]] = {} + + for track in tracks: + track_dict = dict(track) + + # Create grouping key + grouping_key = self.create_grouping_key_string(track_dict) + + # Add to group + if grouping_key not in album_groups: + album_groups[grouping_key] = [] + + album_groups[grouping_key].append(track_dict) + + return album_groups + + except Exception as e: + logger.error(f"Error grouping albums from database: {e}") + return {} + + def create_album_info(self, grouping_key: str, tracks: List[Dict[str, any]]) -> AlbumInfo: + """Create album info from grouped tracks""" + if not tracks: + raise ValueError("No tracks provided") + + first_track = tracks[0] + key = self.create_grouping_key(first_track) + + # Extract unique artists + all_artists = set() + for track in tracks: + artists = self._extract_artists(track) + all_artists.update(artists) + + # Calculate total duration + total_duration = sum(track.get('duration', 0) for track in tracks) + + # Get image from first track (could be enhanced to find best image) + image_url = first_track.get('image') + + return AlbumInfo( + album_id=grouping_key, + title=first_track.get('album', ''), + artists=list(all_artists), + primary_artist=key.normalized_artist, + year=key.year, + album_type=key.album_type, + is_compilation=key.is_compilation, + track_count=len(tracks), + total_duration=total_duration, + image_url=image_url, + folder_path=first_track.get('folderpath', ''), + grouping_key=grouping_key + ) + + def fix_album_grouping_in_database(self) -> int: + """Fix album grouping in database and return number of fixes""" + fixes_made = 0 + + try: + with get_db_connection() as conn: + # Get all tracks + cursor = conn.execute(""" + SELECT trackhash, artist, albumartist, album, date, year, albumtype + FROM tracks + WHERE album IS NOT NULL AND album != '' + """) + + tracks = cursor.fetchall() + + for track in tracks: + track_dict = dict(track) + + # Create proper grouping key + new_key = self.create_grouping_key_string(track_dict) + + # Check if we need to update albumartist + proper_albumartist = self.normalize_album_artist(track_dict) + current_albumartist = track_dict.get('albumartist', '') + + if proper_albumartist != current_albumartist: + cursor = conn.execute(""" + UPDATE tracks + SET albumartist = ? + WHERE trackhash = ? + """, (proper_albumartist, track_dict['trackhash'])) + + fixes_made += 1 + logger.info(f"Fixed albumartist for {track_dict['trackhash']}: '{current_albumartist}' -> '{proper_albumartist}'") + + conn.commit() + + except Exception as e: + logger.error(f"Error fixing album grouping: {e}") + + return fixes_made + + +# Global album grouper instance +album_grouper = AlbumGrouper() diff --git a/src/swingmusic/services/enhanced_directory_scanner.py b/src/swingmusic/services/enhanced_directory_scanner.py new file mode 100644 index 00000000..df5491e7 --- /dev/null +++ b/src/swingmusic/services/enhanced_directory_scanner.py @@ -0,0 +1,452 @@ +""" +Enhanced Directory Scanner for SwingMusic +Handles multiple music directories with parallel scanning, permission validation, and error handling +""" + +import os +import asyncio +import time +from typing import Dict, List, Optional, Set, Tuple, Any +from pathlib import Path +from dataclasses import dataclass +from concurrent.futures import ThreadPoolExecutor, as_completed +import threading +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler + +from swingmusic import logger +from swingmusic.db.sqlite.utils import get_db_connection + + +@dataclass +class ScanResult: + """Result of directory scanning operation""" + directory: str + success: bool + files_found: int + folders_found: int + errors: List[str] + scan_time: float + permissions_ok: bool + + +@dataclass +class FileInfo: + """Information about a scanned file""" + path: str + size: int + modified_time: float + is_audio: bool + extension: str + + +@dataclass +class DirectoryStats: + """Statistics for a scanned directory""" + total_files: int + audio_files: int + total_size: int + last_scan_time: float + scan_duration: float + errors: List[str] + + +class PermissionValidator: + """Validates directory permissions for scanning""" + + @staticmethod + async def validate_directory(directory: str) -> Tuple[bool, List[str]]: + """Validate if directory can be accessed and scanned""" + errors = [] + + try: + path = Path(directory) + + # Check if directory exists + if not path.exists(): + errors.append(f"Directory does not exist: {directory}") + return False, errors + + # Check if it's actually a directory + if not path.is_dir(): + errors.append(f"Path is not a directory: {directory}") + return False, errors + + # Check read permissions + if not os.access(directory, os.R_OK): + errors.append(f"No read permission for directory: {directory}") + return False, errors + + # Check execute permissions (needed for directory traversal) + if not os.access(directory, os.X_OK): + errors.append(f"No execute permission for directory: {directory}") + return False, errors + + # Try to list directory contents + try: + list(path.iterdir()) + except PermissionError as e: + errors.append(f"Cannot list directory contents: {directory} - {str(e)}") + return False, errors + + # Check a subdirectory to ensure traversal works + try: + subdirs = [p for p in path.iterdir() if p.is_dir()] + if subdirs: + test_subdir = subdirs[0] + if os.access(test_subdir, os.R_OK | os.X_OK): + return True, errors + else: + errors.append(f"Cannot access subdirectories in: {directory}") + return False, errors + except Exception as e: + errors.append(f"Error checking subdirectory access: {directory} - {str(e)}") + return False, errors + + return True, errors + + except Exception as e: + errors.append(f"Unexpected error validating directory {directory}: {str(e)}") + return False, errors + + +class ParallelScanner: + """Parallel directory scanner with performance optimization""" + + def __init__(self, max_workers: int = 4): + self.max_workers = max_workers + self.audio_extensions = { + '.flac', '.mp3', '.wav', '.aac', '.m4a', '.ogg', '.wma', + '.alac', '.aiff', '.aif', '.dsd', '.dsf', '.dff' + } + + async def scan_with_progress(self, directory: str, + progress_callback=None) -> ScanResult: + """Scan directory with progress reporting""" + start_time = time.time() + errors = [] + files_found = 0 + folders_found = 0 + + try: + path = Path(directory) + + # Use ThreadPoolExecutor for parallel file processing + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + # Collect all files and directories + all_items = list(path.rglob('*')) + total_items = len(all_items) + + # Process items in batches + batch_size = 100 + processed = 0 + + for i in range(0, total_items, batch_size): + batch = all_items[i:i + batch_size] + + # Process batch in parallel + futures = [] + for item in batch: + future = executor.submit(self._process_item, item) + futures.append((future, item)) + + # Collect results + for future, item in futures: + try: + is_audio, is_dir = future.result(timeout=5) + if is_dir: + folders_found += 1 + elif is_audio: + files_found += 1 + except Exception as e: + errors.append(f"Error processing {item}: {str(e)}") + + processed += len(batch) + + # Report progress + if progress_callback: + progress = (processed / total_items) * 100 + progress_callback(directory, progress, processed, total_items) + + scan_time = time.time() - start_time + + return ScanResult( + directory=directory, + success=len(errors) == 0, + files_found=files_found, + folders_found=folders_found, + errors=errors, + scan_time=scan_time, + permissions_ok=True + ) + + except Exception as e: + scan_time = time.time() - start_time + errors.append(f"Failed to scan directory {directory}: {str(e)}") + + return ScanResult( + directory=directory, + success=False, + files_found=0, + folders_found=0, + errors=errors, + scan_time=scan_time, + permissions_ok=False + ) + + def _process_item(self, item: Path) -> Tuple[bool, bool]: + """Process a single file or directory""" + try: + if item.is_dir(): + return False, True + elif item.is_file(): + is_audio = item.suffix.lower() in self.audio_extensions + return is_audio, False + else: + return False, False + except Exception: + return False, False + + +class DirectoryCache: + """Caches directory scan results to improve performance""" + + def __init__(self, cache_ttl: int = 3600): # 1 hour TTL + self.cache = {} + self.cache_ttl = cache_ttl + + def get(self, directory: str) -> Optional[DirectoryStats]: + """Get cached directory stats""" + cached = self.cache.get(directory) + if cached and (time.time() - cached.last_scan_time) < self.cache_ttl: + return cached + return None + + def set(self, directory: str, stats: DirectoryStats): + """Cache directory stats""" + self.cache[directory] = stats + + def invalidate(self, directory: str): + """Invalidate cache for specific directory""" + self.cache.pop(directory, None) + + def clear(self): + """Clear all cache""" + self.cache.clear() + + +class DirectoryWatcher(FileSystemEventHandler): + """Watches directory changes for automatic rescanning""" + + def __init__(self, directory: str, callback): + self.directory = directory + self.callback = callback + self.debounce_timer = None + self.debounce_delay = 5 # 5 seconds debounce + + def on_created(self, event): + """Handle file/directory creation""" + if not event.is_directory: + self._schedule_rescan() + + def on_deleted(self, event): + """Handle file/directory deletion""" + self._schedule_rescan() + + def on_moved(self, event): + """Handle file/directory moves""" + self._schedule_rescan() + + def _schedule_rescan(self): + """Schedule a rescan with debouncing""" + if self.debounce_timer: + self.debounce_timer.cancel() + + self.debounce_timer = threading.Timer( + self.debounce_delay, + self._trigger_rescan + ) + self.debounce_timer.start() + + def _trigger_rescan(self): + """Trigger the rescan callback""" + try: + self.callback(self.directory) + except Exception as e: + logger.error(f"Error in directory watcher callback: {e}") + + +class EnhancedDirectoryScanner: + """Enhanced directory scanner with multiple improvements""" + + def __init__(self, max_workers: int = 4): + self.permission_validator = PermissionValidator() + self.parallel_scanner = ParallelScanner(max_workers) + self.cache = DirectoryCache() + self.watchers = {} # directory -> observer + self.scan_history = {} + + async def scan_multiple_directories(self, directories: List[str], + progress_callback=None) -> Dict[str, ScanResult]: + """Efficiently scan multiple music directories in parallel""" + logger.info(f"Starting scan of {len(directories)} directories") + + # Validate permissions first + validation_tasks = [] + for directory in directories: + task = self.permission_validator.validate_directory(directory) + validation_tasks.append((directory, task)) + + # Collect validation results + valid_directories = [] + validation_results = {} + + for directory, task in validation_tasks: + permissions_ok, errors = await task + validation_results[directory] = (permissions_ok, errors) + + if permissions_ok: + valid_directories.append(directory) + else: + logger.error(f"Directory validation failed for {directory}: {errors}") + + # Scan valid directories in parallel + scan_tasks = [] + for directory in valid_directories: + task = self.parallel_scanner.scan_with_progress( + directory, progress_callback + ) + scan_tasks.append((directory, task)) + + # Collect scan results + results = {} + for directory, task in scan_tasks: + result = await task + results[directory] = result + + # Cache successful results + if result.success: + stats = DirectoryStats( + total_files=result.files_found + result.folders_found, + audio_files=result.files_found, + total_size=0, # Would need additional calculation + last_scan_time=time.time(), + scan_duration=result.scan_time, + errors=result.errors + ) + self.cache.set(directory, stats) + + # Store in history + self.scan_history[directory] = { + 'last_scan': time.time(), + 'result': result + } + + # Add validation failures to results + for directory, (permissions_ok, errors) in validation_results.items(): + if not permissions_ok: + results[directory] = ScanResult( + directory=directory, + success=False, + files_found=0, + folders_found=0, + errors=errors, + scan_time=0, + permissions_ok=False + ) + + logger.info(f"Completed scan of {len(results)} directories") + return results + + async def scan_directory_async(self, directory: str, + progress_callback=None) -> ScanResult: + """Async directory scanning with progress tracking""" + # Check cache first + cached_stats = self.cache.get(directory) + if cached_stats: + logger.info(f"Using cached results for {directory}") + return ScanResult( + directory=directory, + success=True, + files_found=cached_stats.audio_files, + folders_found=cached_stats.total_files - cached_stats.audio_files, + errors=cached_stats.errors, + scan_time=cached_stats.scan_duration, + permissions_ok=True + ) + + # Validate permissions + permissions_ok, errors = await self.permission_validator.validate_directory(directory) + if not permissions_ok: + return ScanResult( + directory=directory, + success=False, + files_found=0, + folders_found=0, + errors=errors, + scan_time=0, + permissions_ok=False + ) + + # Perform scan + result = await self.parallel_scanner.scan_with_progress( + directory, progress_callback + ) + + # Cache successful results + if result.success: + stats = DirectoryStats( + total_files=result.files_found + result.folders_found, + audio_files=result.files_found, + total_size=0, + last_scan_time=time.time(), + scan_duration=result.scan_time, + errors=result.errors + ) + self.cache.set(directory, stats) + + return result + + def start_watching(self, directory: str, callback): + """Start watching a directory for changes""" + if directory in self.watchers: + return # Already watching + + try: + observer = Observer() + handler = DirectoryWatcher(directory, callback) + observer.schedule(handler, directory, recursive=True) + observer.start() + self.watchers[directory] = observer + logger.info(f"Started watching directory: {directory}") + except Exception as e: + logger.error(f"Failed to start watching {directory}: {e}") + + def stop_watching(self, directory: str): + """Stop watching a directory""" + if directory in self.watchers: + observer = self.watchers.pop(directory) + observer.stop() + observer.join() + logger.info(f"Stopped watching directory: {directory}") + + def stop_all_watching(self): + """Stop watching all directories""" + for directory in list(self.watchers.keys()): + self.stop_watching(directory) + + def get_scan_stats(self) -> Dict[str, Any]: + """Get scanning statistics""" + return { + 'cached_directories': len(self.cache.cache), + 'watched_directories': len(self.watchers), + 'scan_history': len(self.scan_history), + 'last_scans': { + directory: history['last_scan'] + for directory, history in self.scan_history.items() + } + } + + +# Global enhanced directory scanner instance +enhanced_directory_scanner = EnhancedDirectoryScanner() diff --git a/src/swingmusic/services/enhanced_ui_performance.py b/src/swingmusic/services/enhanced_ui_performance.py new file mode 100644 index 00000000..29246714 --- /dev/null +++ b/src/swingmusic/services/enhanced_ui_performance.py @@ -0,0 +1,455 @@ +""" +Enhanced UI Performance Service for SwingMusic +Provides virtual scrolling, lazy loading, and performance optimizations for large libraries +""" + +import asyncio +import time +from typing import Dict, List, Optional, Any, Callable, Tuple +from dataclasses import dataclass +from enum import Enum +import json +from pathlib import Path + +from swingmusic import logger +from swingmusic.db.sqlite.utils import get_db_connection + + +class ItemType(Enum): + TRACK = "track" + ALBUM = "album" + ARTIST = "artist" + PLAYLIST = "playlist" + FOLDER = "folder" + + +@dataclass +class VirtualItem: + """Item in a virtual list""" + id: str + item_type: ItemType + title: str + subtitle: str + image_url: Optional[str] + data: Dict[str, Any] + index: int + height: int = 60 + loaded: bool = False + visible: bool = False + + +@dataclass +class ViewportConfig: + """Viewport configuration for virtual scrolling""" + item_height: int = 60 + viewport_height: int = 600 + buffer_size: int = 10 + overscan: int = 5 + + +@dataclass +class PerformanceMetrics: + """Performance metrics for UI operations""" + render_time: float + item_count: int + visible_items: int + memory_usage: int + scroll_fps: float + + +class VirtualScrollManager: + """Manages virtual scrolling for large lists""" + + def __init__(self, config: ViewportConfig): + self.config = config + self.items: List[VirtualItem] = [] + self.visible_start = 0 + self.visible_end = 0 + self.scroll_top = 0 + self.last_render_time = 0 + self.render_callbacks: List[Callable] = [] + + def set_items(self, items: List[VirtualItem]): + """Set the items for virtual scrolling""" + self.items = items + self._update_visible_range() + + def update_scroll_position(self, scroll_top: int): + """Update scroll position and recalculate visible items""" + self.scroll_top = scroll_top + self._update_visible_range() + + def _update_visible_range(self): + """Calculate which items should be visible""" + if not self.items: + self.visible_start = 0 + self.visible_end = 0 + return + + start_index = max(0, self.scroll_top // self.config.item_height - self.config.overscan) + end_index = min( + len(self.items), + ((self.scroll_top + self.config.viewport_height) // self.config.item_height) + self.config.overscan + ) + + self.visible_start = start_index + self.visible_end = end_index + + # Update item visibility + for i, item in enumerate(self.items): + item.visible = start_index <= i < end_index + + def get_visible_items(self) -> List[VirtualItem]: + """Get currently visible items""" + return self.items[self.visible_start:self.visible_end] + + def get_total_height(self) -> int: + """Get total height of all items""" + return len(self.items) * self.config.item_height + + def get_offset_y(self) -> int: + """Get Y offset for visible items""" + return self.visible_start * self.config.item_height + + def add_render_callback(self, callback: Callable): + """Add callback for render events""" + self.render_callbacks.append(callback) + + def trigger_render(self): + """Trigger render with performance tracking""" + start_time = time.time() + + # Notify callbacks + for callback in self.render_callbacks: + try: + callback() + except Exception as e: + logger.error(f"Error in render callback: {e}") + + self.last_render_time = time.time() - start_time + + +class LazyImageLoader: + """Manages lazy loading of images with intersection observer simulation""" + + def __init__(self, max_concurrent: int = 6): + self.max_concurrent = max_concurrent + self.loading_queue: List[Tuple[str, Callable]] = [] + self.loading_images: Set[str] = set() + self.loaded_images: Dict[str, str] = {} + self.failed_images: Set[str] = set() + + def load_image(self, image_url: str, callback: Callable[[str], None]): + """Load an image with callback""" + if image_url in self.loaded_images: + callback(self.loaded_images[image_url]) + return + + if image_url in self.failed_images: + callback("") # Return empty string for failed images + return + + if image_url in self.loading_images: + # Already loading, add to queue + self.loading_queue.append((image_url, callback)) + return + + self._start_loading(image_url, callback) + + def _start_loading(self, image_url: str, callback: Callable[[str], None]): + """Start loading an image""" + if len(self.loading_images) >= self.max_concurrent: + self.loading_queue.append((image_url, callback)) + return + + self.loading_images.add(image_url) + + # Simulate image loading (in real implementation, use actual image loading) + asyncio.create_task(self._load_image_async(image_url, callback)) + + async def _load_image_async(self, image_url: str, callback: Callable[[str], None]): + """Async image loading simulation""" + try: + # Simulate network delay + await asyncio.sleep(0.1) + + # In real implementation, load actual image data + # For now, just return the URL as "loaded" + self.loaded_images[image_url] = image_url + + # Remove from loading set + self.loading_images.discard(image_url) + + # Call callback + callback(image_url) + + # Process next in queue + if self.loading_queue: + next_url, next_callback = self.loading_queue.pop(0) + self._start_loading(next_url, next_callback) + + except Exception as e: + logger.error(f"Error loading image {image_url}: {e}") + self.loading_images.discard(image_url) + self.failed_images.add(image_url) + callback("") + + def preload_images(self, image_urls: List[str]): + """Preload a list of images""" + for url in image_urls: + if url not in self.loaded_images and url not in self.failed_images: + self.load_image(url, lambda _: None) + + +class PerformanceOptimizer: + """Optimizes UI performance for large datasets""" + + def __init__(self): + self.metrics: List[PerformanceMetrics] = [] + self.debounce_timers: Dict[str, float] = {} + self.throttle_intervals: Dict[str, float] = {} + + def debounce(self, key: str, func: Callable, delay: float = 0.1): + """Debounce function calls""" + current_time = time.time() + + if key in self.debounce_timers: + if current_time - self.debounce_timers[key] < delay: + return + + self.debounce_timers[key] = current_time + asyncio.create_task(self._debounce_async(key, func, delay)) + + async def _debounce_async(self, key: str, func: Callable, delay: float): + """Async debounce implementation""" + await asyncio.sleep(delay) + + # Check if still the latest call + if key in self.debounce_timers: + try: + func() + except Exception as e: + logger.error(f"Error in debounced function: {e}") + + def throttle(self, key: str, func: Callable, interval: float = 0.016): # 60fps + """Throttle function calls""" + current_time = time.time() + + if key in self.throttle_intervals: + if current_time - self.throttle_intervals[key] < interval: + return + + self.throttle_intervals[key] = current_time + try: + func() + except Exception as e: + logger.error(f"Error in throttled function: {e}") + + def measure_performance(self, operation: str, func: Callable) -> Any: + """Measure performance of an operation""" + start_time = time.time() + start_memory = self._get_memory_usage() + + try: + result = func() + end_time = time.time() + end_memory = self._get_memory_usage() + + metrics = PerformanceMetrics( + render_time=end_time - start_time, + item_count=0, # Would be context-specific + visible_items=0, + memory_usage=end_memory - start_memory, + scroll_fps=1.0 / (end_time - start_time) if end_time > start_time else 0 + ) + + self.metrics.append(metrics) + logger.debug(f"Performance metrics for {operation}: {metrics.render_time:.3f}s") + + return result + + except Exception as e: + logger.error(f"Error in performance measurement for {operation}: {e}") + raise + + def _get_memory_usage(self) -> int: + """Get current memory usage (simplified)""" + try: + import psutil + return psutil.Process().memory_info().rss + except ImportError: + return 0 + + def get_average_performance(self) -> Optional[PerformanceMetrics]: + """Get average performance metrics""" + if not self.metrics: + return None + + avg_render_time = sum(m.render_time for m in self.metrics) / len(self.metrics) + avg_memory = sum(m.memory_usage for m in self.metrics) / len(self.metrics) + avg_fps = sum(m.scroll_fps for m in self.metrics) / len(self.metrics) + + return PerformanceMetrics( + render_time=avg_render_time, + item_count=sum(m.item_count for m in self.metrics), + visible_items=sum(m.visible_items for m in self.metrics), + memory_usage=int(avg_memory), + scroll_fps=avg_fps + ) + + +class EnhancedUIManager: + """Enhanced UI manager with performance optimizations""" + + def __init__(self): + self.virtual_scroll = VirtualScrollManager(ViewportConfig()) + self.image_loader = LazyImageLoader() + self.performance_optimizer = PerformanceOptimizer() + self.cached_data: Dict[str, Any] = {} + self.cache_ttl = 300 # 5 minutes + + async def get_tracks_paginated(self, offset: int = 0, limit: int = 50, + filters: Dict[str, Any] = None) -> Dict[str, Any]: + """Get tracks with pagination and caching""" + cache_key = f"tracks_{offset}_{limit}_{json.dumps(filters or {})}" + + # Check cache + if cache_key in self.cached_data: + cached_time, cached_data = self.cached_data[cache_key] + if time.time() - cached_time < self.cache_ttl: + return cached_data + + # Fetch from database + try: + with get_db_connection() as conn: + query = """ + SELECT t.trackhash, t.title, t.artists, t.album, t.duration, + t.bitrate, t.image, t.folderpath, t.filename + FROM tracks t + """ + + conditions = [] + params = [] + + if filters: + if 'artist' in filters: + conditions.append("t.artists LIKE ?") + params.append(f"%{filters['artist']}%") + + if 'album' in filters: + conditions.append("t.album LIKE ?") + params.append(f"%{filters['album']}%") + + if 'genre' in filters: + # Would need genre table join + pass + + if conditions: + query += " WHERE " + " AND ".join(conditions) + + query += " ORDER BY t.artists, t.album, t.tracknumber LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + cursor = conn.execute(query, params) + tracks = cursor.fetchall() + + # Get total count + count_query = "SELECT COUNT(*) FROM tracks t" + if conditions: + count_query += " WHERE " + " AND ".join(conditions) + + cursor = conn.execute(count_query, params[:-2]) # Exclude limit/offset + total_count = cursor.fetchone()[0] + + result = { + 'tracks': [dict(track) for track in tracks], + 'total': total_count, + 'offset': offset, + 'limit': limit + } + + # Cache result + self.cached_data[cache_key] = (time.time(), result) + + return result + + except Exception as e: + logger.error(f"Error fetching tracks: {e}") + return {'tracks': [], 'total': 0, 'offset': offset, 'limit': limit} + + def create_virtual_items(self, tracks: List[Dict[str, Any]]) -> List[VirtualItem]: + """Create virtual items from track data""" + items = [] + + for i, track in enumerate(tracks): + item = VirtualItem( + id=track['trackhash'], + item_type=ItemType.TRACK, + title=track['title'], + subtitle=f"{track['artists']} • {track['album']}", + image_url=track.get('image'), + data=track, + index=i + ) + items.append(item) + + return items + + def optimize_scroll_performance(self, scroll_callback: Callable): + """Optimize scroll performance with throttling""" + def optimized_scroll(scroll_top: int): + self.performance_optimizer.throttle( + 'scroll', + lambda: self._handle_scroll(scroll_top, scroll_callback), + 0.016 # 60fps + ) + + return optimized_scroll + + def _handle_scroll(self, scroll_top: int, callback: Callable): + """Handle scroll with virtual scrolling""" + self.virtual_scroll.update_scroll_position(scroll_top) + callback() + + def preload_nearby_images(self, visible_items: List[VirtualItem]): + """Preload images for visible and nearby items""" + image_urls = [] + + for item in visible_items: + if item.image_url: + image_urls.append(item.image_url) + + # Add nearby items for smoother scrolling + start = max(0, self.virtual_scroll.visible_start - 5) + end = min(len(self.virtual_scroll.items), self.virtual_scroll.visible_end + 5) + + for item in self.virtual_scroll.items[start:end]: + if item.image_url and item.image_url not in image_urls: + image_urls.append(item.image_url) + + self.image_loader.preload_images(image_urls) + + def clear_cache(self): + """Clear all caches""" + self.cached_data.clear() + self.image_loader.loaded_images.clear() + self.image_loader.failed_images.clear() + + def get_performance_report(self) -> Dict[str, Any]: + """Get performance report""" + avg_metrics = self.performance_optimizer.get_average_performance() + + return { + 'average_render_time': avg_metrics.render_time if avg_metrics else 0, + 'average_fps': avg_metrics.scroll_fps if avg_metrics else 0, + 'memory_usage': avg_metrics.memory_usage if avg_metrics else 0, + 'cached_items': len(self.cached_data), + 'loaded_images': len(self.image_loader.loaded_images), + 'failed_images': len(self.image_loader.failed_images), + 'virtual_items': len(self.virtual_scroll.items), + 'visible_items': len(self.virtual_scroll.get_visible_items()) + } + + +# Global enhanced UI manager instance +enhanced_ui_manager = EnhancedUIManager() diff --git a/src/swingmusic/services/ios_audio_compatibility.py b/src/swingmusic/services/ios_audio_compatibility.py new file mode 100644 index 00000000..c29e560a --- /dev/null +++ b/src/swingmusic/services/ios_audio_compatibility.py @@ -0,0 +1,228 @@ +""" +iOS Audio Compatibility Service for SwingMusic +Handles iOS-specific audio playback issues and format compatibility +""" + +import os +import re +import subprocess +import tempfile +from typing import Optional, Dict, Any, Tuple +from pathlib import Path +from dataclasses import dataclass + +from swingmusic import logger +from swingmusic.utils.files import guess_mime_type + + +@dataclass +class IOSAudioCapabilities: + """iOS device audio capabilities""" + is_safari: bool + is_ios: bool + supports_flac: bool + supports_webm: bool + supports_alac: bool + supports_aac: bool + user_agent: str + optimal_format: str + optimal_codec: str + + +class IOSAudioManager: + """Manages iOS audio compatibility and transcoding""" + + def __init__(self): + self.temp_dir = tempfile.gettempdir() + self.transcode_cache = {} + + def detect_ios_capabilities(self, user_agent: str = "") -> IOSAudioCapabilities: + """Detect iOS device capabilities from user agent""" + is_safari = 'Safari' in user_agent and 'Chrome' not in user_agent + is_ios = bool(re.search(r'iPad|iPhone|iPod', user_agent)) + + # iOS format support matrix + supports_flac = False # iOS doesn't support FLAC natively + supports_webm = False # Limited WebM support on iOS + supports_alac = True # Apple Lossless supported on iOS + supports_aac = True # AAC widely supported + + # Determine optimal format for iOS + if is_ios: + if supports_alac: + optimal_format = 'mp4' # ALAC in MP4 container + optimal_codec = 'alac' + else: + optimal_format = 'mp4' # AAC in MP4 container + optimal_codec = 'aac' + else: + optimal_format = 'flac' # Use original format for non-iOS + optimal_codec = 'flac' + + return IOSAudioCapabilities( + is_safari=is_safari, + is_ios=is_ios, + supports_flac=supports_flac, + supports_webm=supports_webm, + supports_alac=supports_alac, + supports_aac=supports_aac, + user_agent=user_agent, + optimal_format=optimal_format, + optimal_codec=optimal_codec + ) + + def needs_transcoding(self, file_path: str, capabilities: IOSAudioCapabilities) -> bool: + """Check if file needs transcoding for iOS compatibility""" + if not capabilities.is_ios: + return False + + original_mime = guess_mime_type(file_path) + + # iOS doesn't support FLAC, need transcoding + if original_mime == 'audio/flac' and not capabilities.supports_flac: + return True + + # iOS has limited WebM support + if original_mime == 'audio/webm' and not capabilities.supports_webm: + return True + + return False + + def get_optimal_audio_format(self, file_path: str, capabilities: IOSAudioCapabilities) -> Tuple[str, str]: + """Get optimal audio format and codec for the device""" + if not capabilities.is_ios: + # Return original format for non-iOS devices + original_mime = guess_mime_type(file_path) + if original_mime == 'audio/flac': + return 'flac', 'flac' + elif original_mime == 'audio/mpeg': + return 'mp3', 'mp3' + else: + return 'mp4', 'aac' + + # Return iOS-optimized format + return capabilities.optimal_format, capabilities.optimal_codec + + def transcode_for_ios(self, file_path: str, capabilities: IOSAudioCapabilities, + quality: str = "high") -> Optional[str]: + """Transcode audio file for iOS compatibility""" + try: + # Check if already transcoded + cache_key = f"{file_path}_{capabilities.optimal_format}_{quality}" + if cache_key in self.transcode_cache: + cached_file = self.transcode_cache[cache_key] + if os.path.exists(cached_file): + return cached_file + + # Create output file path + input_path = Path(file_path) + output_filename = f"{input_path.stem}_ios_{capabilities.optimal_format}.{capabilities.optimal_format}" + output_path = os.path.join(self.temp_dir, output_filename) + + # Prepare FFmpeg command based on target format + if capabilities.optimal_codec == 'alac': + # Apple Lossless Audio Codec + cmd = [ + 'ffmpeg', '-i', file_path, + '-c:a', 'alac', + '-ar', '44100', # Sample rate + '-ac', '2', # Stereo + '-y', output_path + ] + elif capabilities.optimal_codec == 'aac': + # AAC codec with quality settings + bitrate_map = { + 'low': '96k', + 'medium': '128k', + 'high': '256k', + 'lossless': '320k' + } + bitrate = bitrate_map.get(quality, '256k') + + cmd = [ + 'ffmpeg', '-i', file_path, + '-c:a', 'aac', + '-b:a', bitrate, + '-ar', '44100', + '-ac', '2', + '-y', output_path + ] + else: + # Default to AAC + cmd = [ + 'ffmpeg', '-i', file_path, + '-c:a', 'aac', + '-b:a', '256k', + '-ar', '44100', + '-ac', '2', + '-y', output_path + ] + + # Execute transcoding + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode == 0 and os.path.exists(output_path): + # Cache the transcoded file + self.transcode_cache[cache_key] = output_path + logger.info(f"Successfully transcoded {file_path} for iOS: {output_path}") + return output_path + else: + logger.error(f"FFmpeg transcoding failed: {result.stderr}") + return None + + except Exception as e: + logger.error(f"Error transcoding for iOS: {e}") + return None + + def get_ios_compatible_mime_type(self, file_path: str, capabilities: IOSAudioCapabilities) -> str: + """Get iOS-compatible MIME type""" + if not capabilities.is_ios: + return guess_mime_type(file_path) + + if capabilities.optimal_format == 'mp4': + if capabilities.optimal_codec == 'alac': + return 'audio/mp4' # ALAC in MP4 container + else: + return 'audio/mp4' # AAC in MP4 container + elif capabilities.optimal_format == 'mp3': + return 'audio/mpeg' + else: + return 'audio/mp4' # Default to MP4 container for iOS + + def create_ios_audio_source(self, file_path: str, capabilities: IOSAudioCapabilities, + quality: str = "high") -> Dict[str, Any]: + """Create iOS-compatible audio source configuration""" + source_config = { + 'file_path': file_path, + 'needs_transcoding': self.needs_transcoding(file_path, capabilities), + 'mime_type': self.get_ios_compatible_mime_type(file_path, capabilities), + 'format': capabilities.optimal_format, + 'codec': capabilities.optimal_codec + } + + if source_config['needs_transcoding']: + transcoded_path = self.transcode_for_ios(file_path, capabilities, quality) + if transcoded_path: + source_config['transcoded_path'] = transcoded_path + source_config['file_path'] = transcoded_path + else: + # Fallback to original file if transcoding fails + logger.warning(f"Transcoding failed, using original file: {file_path}") + source_config['needs_transcoding'] = False + source_config['mime_type'] = guess_mime_type(file_path) + + return source_config + + def cleanup_transcoded_files(self): + """Clean up temporary transcoded files""" + try: + for cached_file in self.transcode_cache.values(): + if os.path.exists(cached_file): + os.remove(cached_file) + self.transcode_cache.clear() + except Exception as e: + logger.error(f"Error cleaning up transcoded files: {e}") + + +# Global iOS audio manager instance +ios_audio_manager = IOSAudioManager() diff --git a/src/swingmusic/services/library_integration.py b/src/swingmusic/services/library_integration.py new file mode 100644 index 00000000..05135923 --- /dev/null +++ b/src/swingmusic/services/library_integration.py @@ -0,0 +1,283 @@ +""" +Library integration service for Spotify downloads +Handles automatic addition of downloaded tracks to SwingMusic library +""" + +import os +import hashlib +from pathlib import Path +from typing import Optional, Dict, Any +from datetime import datetime + +from swingmusic.db.libdata import TrackTable +from swingmusic.db.engine import DbEngine +from swingmusic.config import UserConfig +from swingmusic.utils import create_valid_filename +from swingmusic import logger + + +class LibraryIntegrator: + """Handles integration of downloaded tracks into SwingMusic library""" + + def __init__(self): + self.config = UserConfig() + self.music_dirs = self.config.rootDirs + + def add_downloaded_track(self, download_item: Dict[str, Any]) -> bool: + """ + Add a downloaded track to the SwingMusic library + + Args: + download_item: Dictionary containing download information + + Returns: + bool: True if successfully added, False otherwise + """ + try: + if not download_item.get('file_path') or not os.path.exists(download_item['file_path']): + logger.error(f"Downloaded file not found: {download_item.get('file_path')}") + return False + + # Check if track already exists in library + if self._track_exists(download_item['file_path']): + logger.info(f"Track already exists in library: {download_item['file_path']}") + return True + + # Create track record + track_data = self._create_track_data(download_item) + + # Insert into database + self._insert_track(track_data) + + logger.info(f"Added track to library: {track_data['title']} by {track_data['artists']}") + return True + + except Exception as e: + logger.error(f"Error adding track to library: {e}") + return False + + def add_downloaded_album(self, download_item: Dict[str, Any], track_files: list[str]) -> int: + """ + Add all tracks from a downloaded album to the library + + Args: + download_item: Album download information + track_files: List of downloaded track file paths + + Returns: + int: Number of tracks successfully added + """ + added_count = 0 + + try: + for track_file in track_files: + if not os.path.exists(track_file): + logger.warning(f"Track file not found: {track_file}") + continue + + # Check if track already exists + if self._track_exists(track_file): + logger.info(f"Track already exists in library: {track_file}") + added_count += 1 + continue + + # Create track data for album track + track_data = self._create_album_track_data(download_item, track_file) + + # Insert into database + self._insert_track(track_data) + added_count += 1 + + logger.info(f"Added {added_count} tracks from album to library") + return added_count + + except Exception as e: + logger.error(f"Error adding album to library: {e}") + return added_count + + def _track_exists(self, filepath: str) -> bool: + """Check if track already exists in library""" + try: + with DbEngine.manager() as conn: + result = conn.execute( + TrackTable.select().where(TrackTable.filepath == filepath) + ) + return result.scalar() is not None + except Exception as e: + logger.error(f"Error checking if track exists: {e}") + return False + + def _create_track_data(self, download_item: Dict[str, Any]) -> Dict[str, Any]: + """Create track data dictionary from download item""" + filepath = download_item['file_path'] + file_stat = os.stat(filepath) + + # Extract metadata from download item + title = download_item.get('title', 'Unknown Title') + artist = download_item.get('artist', 'Unknown Artist') + album = download_item.get('album', 'Unknown Album') + + # Generate hashes + trackhash = self._generate_track_hash(filepath, title, artist) + albumhash = self._generate_album_hash(album, artist) + + # Extract file information + folder = os.path.basename(os.path.dirname(filepath)) + + return { + 'title': title, + 'artists': artist, + 'albumartists': artist, + 'album': album, + 'albumhash': albumhash, + 'trackhash': trackhash, + 'filepath': filepath, + 'folder': folder, + 'duration': download_item.get('duration_ms', 0) // 1000, # Convert to seconds + 'bitrate': self._get_bitrate_from_quality(download_item.get('quality', 'flac')), + 'date': self._parse_date(download_item.get('release_date')), + 'track': download_item.get('track_number', 1), + 'disc': 1, + 'last_mod': int(file_stat.st_mtime), + 'extra': { + 'spotify_id': download_item.get('spotify_id'), + 'source': download_item.get('source', 'spotify'), + 'download_date': datetime.now().isoformat() + } + } + + def _create_album_track_data(self, download_item: Dict[str, Any], track_file: str) -> Dict[str, Any]: + """Create track data for album track""" + file_stat = os.stat(track_file) + + # Extract filename for title (if metadata not available) + filename = os.path.splitext(os.path.basename(track_file))[0] + + # Use download item metadata as base + title = download_item.get('title', filename) + artist = download_item.get('artist', 'Unknown Artist') + album = download_item.get('album', 'Unknown Album') + + # Generate hashes + trackhash = self._generate_track_hash(track_file, title, artist) + albumhash = self._generate_album_hash(album, artist) + + # Extract file information + folder = os.path.basename(os.path.dirname(track_file)) + + return { + 'title': title, + 'artists': artist, + 'albumartists': artist, + 'album': album, + 'albumhash': albumhash, + 'trackhash': trackhash, + 'filepath': track_file, + 'folder': folder, + 'duration': download_item.get('duration_ms', 0) // 1000, + 'bitrate': self._get_bitrate_from_quality(download_item.get('quality', 'flac')), + 'date': self._parse_date(download_item.get('release_date')), + 'track': download_item.get('track_number', 1), + 'disc': 1, + 'last_mod': int(file_stat.st_mtime), + 'extra': { + 'spotify_id': download_item.get('spotify_id'), + 'source': download_item.get('source', 'spotify'), + 'download_date': datetime.now().isoformat(), + 'album_download': True + } + } + + def _insert_track(self, track_data: Dict[str, Any]): + """Insert track into database""" + try: + with DbEngine.manager(commit=True) as conn: + conn.execute(TrackTable.insert().values(track_data)) + except Exception as e: + logger.error(f"Error inserting track: {e}") + raise + + def _generate_track_hash(self, filepath: str, title: str, artist: str) -> str: + """Generate unique track hash""" + content = f"{filepath}:{title}:{artist}" + return hashlib.md5(content.encode()).hexdigest() + + def _generate_album_hash(self, album: str, artist: str) -> str: + """Generate album hash""" + content = f"{album}:{artist}" + return hashlib.md5(content.encode()).hexdigest() + + def _get_bitrate_from_quality(self, quality: str) -> int: + """Get approximate bitrate based on quality""" + quality_bitrates = { + 'flac': 1411, # Approximate FLAC bitrate + 'mp3_320': 320, + 'mp3_128': 128 + } + return quality_bitrates.get(quality, 320) + + def _parse_date(self, date_str: Optional[str]) -> Optional[int]: + """Parse date string to timestamp""" + if not date_str: + return None + + try: + # Try various date formats + formats = ['%Y-%m-%d', '%Y', '%Y-%m'] + for fmt in formats: + try: + dt = datetime.strptime(date_str, fmt) + return int(dt.timestamp()) + except ValueError: + continue + + return None + except Exception: + return None + + def remove_downloaded_track(self, filepath: str) -> bool: + """ + Remove a downloaded track from the library + + Args: + filepath: Path to the track file + + Returns: + bool: True if successfully removed + """ + try: + with DbEngine.manager(commit=True) as conn: + result = conn.execute( + TrackTable.delete().where(TrackTable.filepath == filepath) + ) + return result.rowcount > 0 + except Exception as e: + logger.error(f"Error removing track from library: {e}") + return False + + def update_track_metadata(self, filepath: str, metadata: Dict[str, Any]) -> bool: + """ + Update metadata for a track in the library + + Args: + filepath: Path to the track file + metadata: New metadata to apply + + Returns: + bool: True if successfully updated + """ + try: + with DbEngine.manager(commit=True) as conn: + result = conn.execute( + TrackTable.update() + .where(TrackTable.filepath == filepath) + .values(metadata) + ) + return result.rowcount > 0 + except Exception as e: + logger.error(f"Error updating track metadata: {e}") + return False + + +# Global instance +library_integrator = LibraryIntegrator() diff --git a/src/swingmusic/services/metadata_aggregator.py b/src/swingmusic/services/metadata_aggregator.py new file mode 100644 index 00000000..c3b3e45b --- /dev/null +++ b/src/swingmusic/services/metadata_aggregator.py @@ -0,0 +1,296 @@ +""" +Enhanced Metadata Aggregation System for Universal Music Downloader +Provides cross-service matching and metadata enrichment without API keys +""" + +import re +import asyncio +from typing import Dict, List, Optional, Tuple, Any +from dataclasses import dataclass +import logging + +logger = logging.getLogger(__name__) + + +@dataclass +class CrossServiceMatch: + """Cross-service song match information""" + service: str + service_id: str + title: str + artist: str + url: str + confidence: float + isrc: Optional[str] = None + duration_ms: Optional[int] = None + release_date: Optional[str] = None + cover_art: Optional[str] = None + + +@dataclass +class EnhancedMetadata: + """Enhanced metadata with cross-service information""" + primary_metadata: Any + cross_matches: List[CrossServiceMatch] + canonical_info: Optional[Dict[str, Any]] = None + confidence_score: float = 0.0 + recommendations: List[str] = None + + +class MetadataAggregator: + """Aggregates and enhances metadata from multiple sources""" + + def __init__(self): + self.canonical_cache = {} + self.artist_aliases = {} + + def normalize_title(self, title: str) -> str: + """Normalize song title for better matching""" + # Remove extra whitespace and convert to lowercase + normalized = title.strip().lower() + + # Remove common prefixes and suffixes + prefixes_to_remove = ['official video', 'official audio', 'lyrics', 'live', 'acoustic', 'remastered'] + for prefix in prefixes_to_remove: + normalized = re.sub(rf'\s*{prefix}\s*', '', normalized, flags=re.IGNORECASE) + + # Remove content in parentheses + normalized = re.sub(r'\s*\([^)]*\)\s*', '', normalized) + + # Remove extra dashes and special characters + normalized = re.sub(r'\s*[-–—]\s*', ' ', normalized) + + return normalized.strip() + + def normalize_artist(self, artist: str) -> str: + """Normalize artist name for better matching""" + normalized = artist.strip().lower() + + # Remove "feat." and similar + normalized = re.sub(r'\s*feat\.\s*', ' feat. ', normalized) + + # Handle "vs" collaborations + normalized = re.sub(r'\s+vs\s+', ' vs ', normalized) + + return normalized.strip() + + def calculate_similarity_score(self, title1: str, artist1: str, title2: str, artist2: str) -> float: + """Calculate similarity score between two songs""" + title_score = 0.0 + artist_score = 0.0 + + # Title similarity + if title1 and title2: + norm_title1 = self.normalize_title(title1) + norm_title2 = self.normalize_title(title2) + + if norm_title1 == norm_title2: + title_score = 1.0 + elif norm_title1 in norm_title2 or norm_title2 in norm_title1: + title_score = 0.8 + else: + # Partial match based on words + words1 = set(norm_title1.split()) + words2 = set(norm_title2.split()) + common_words = words1.intersection(words2) + title_score = len(common_words) / max(len(words1), len(words2)) if words1 and words2 else 0.0 + + # Artist similarity + if artist1 and artist2: + norm_artist1 = self.normalize_artist(artist1) + norm_artist2 = self.normalize_artist(artist2) + + if norm_artist1 == norm_artist2: + artist_score = 1.0 + elif norm_artist1 in norm_artist2 or norm_artist2 in norm_artist1: + artist_score = 0.8 + else: + # Partial match based on words + words1 = set(norm_artist1.split()) + words2 = set(norm_artist2.split()) + common_words = words1.intersection(words2) + artist_score = len(common_words) / max(len(words1), len(words2)) if words1 and words2 else 0.0 + + # Combined score (title is more important) + return (title_score * 0.7 + artist_score * 0.3) + + def find_cross_service_matches(self, primary_metadata: Any, all_services_data: Dict[str, Any]) -> List[CrossServiceMatch]: + """Find matches of the same song across other services""" + matches = [] + + if not primary_metadata: + return matches + + primary_title = getattr(primary_metadata, 'title', '') + primary_artist = getattr(primary_metadata, 'artist', '') + primary_isrc = getattr(primary_metadata, 'isrc', None) + + for service, data in all_services_data.items(): + service_attr = getattr(primary_metadata, 'service', None) + if service_attr and service == service_attr.value: + continue # Skip: same service + + service_title = getattr(data, 'title', '') + service_artist = getattr(data, 'artist', '') + service_url = getattr(data, 'original_url', '') + + # Calculate similarity score + similarity = self.calculate_similarity_score( + primary_title, primary_artist, + service_title, service_artist + ) + + # Only include matches with reasonable similarity + if similarity >= 0.6: # 60% similarity threshold + match = CrossServiceMatch( + service=service, + service_id=getattr(data, 'service_id', ''), + title=service_title, + artist=service_artist, + url=service_url, + confidence=similarity, + isrc=getattr(data, 'isrc', None), + duration_ms=getattr(data, 'duration_ms', None), + release_date=getattr(data, 'release_date', None), + cover_art=getattr(data, 'image_url', None) + ) + matches.append(match) + + # Sort by confidence score + matches.sort(key=lambda x: x.confidence, reverse=True) + return matches + + def get_canonical_info(self, isrc: str) -> Optional[Dict[str, Any]]: + """Get canonical information from ISRC""" + if not isrc or len(isrc) != 12: + return None + + # Parse ISRC: Country-Registration Year-Sequence Number + country = isrc[:2] + year = isrc[2:6] + sequence = isrc[6:] + + return { + 'isrc': isrc, + 'country': country, + 'year': year, + 'sequence': sequence, + 'type': 'recording' if sequence.isdigit() else 'other' + } + + def generate_recommendations(self, metadata: Any, cross_matches: List[CrossServiceMatch]) -> List[str]: + """Generate recommendations based on metadata and cross matches""" + recommendations = [] + + # Base recommendations on genre + genre = getattr(metadata, 'genre', '') + if genre: + recommendations.append(f"Similar {genre} tracks") + + # Add recommendations from high-confidence cross matches + high_confidence_matches = [m for m in cross_matches if m.confidence >= 0.8] + for match in high_confidence_matches[:3]: # Top 3 matches + recommendations.append(f"Also available on {match.service}") + + # Add recommendations based on artist + artist = getattr(metadata, 'artist', '') + if artist: + recommendations.append(f"More from {artist}") + + return list(set(recommendations)) # Remove duplicates + + def create_enhanced_metadata(self, primary_metadata: Any, cross_matches: List[CrossServiceMatch]) -> EnhancedMetadata: + """Create enhanced metadata object""" + # Calculate confidence score + max_confidence = max([m.confidence for m in cross_matches]) if cross_matches else 0.0 + + # Get canonical info if ISRC exists + canonical_info = None + isrc = getattr(primary_metadata, 'isrc', None) + if isrc: + canonical_info = self.get_canonical_info(isrc) + + # Generate recommendations + recommendations = self.generate_recommendations(primary_metadata, cross_matches) + + return EnhancedMetadata( + primary_metadata=primary_metadata, + cross_matches=cross_matches, + canonical_info=canonical_info, + confidence_score=max_confidence, + recommendations=recommendations + ) + + +class FreeMetadataEnricher: + """Free metadata enrichment without API keys""" + + def __init__(self): + self.aggregator = MetadataAggregator() + + def extract_lyrics_snippet(self, title: str, artist: str) -> str: + """Extract potential lyrics snippet for search enhancement""" + # This would use web scraping of lyrics sites + # For now, return empty to avoid copyright issues + return "" + + def detect_language(self, title: str, artist: str) -> str: + """Detect likely language from title and artist""" + # Simple heuristic based on character patterns + if any(ord(c) > 127 for c in title + artist): + return "non-english" + return "english" + + def estimate_mood(self, title: str, artist: str) -> str: + """Estimate mood from title and artist name""" + title_lower = title.lower() + artist_lower = artist.lower() + + mood_keywords = { + 'happy': ['love', 'joy', 'sun', 'summer', 'dance', 'party'], + 'sad': ['cry', 'tears', 'rain', 'winter', 'goodbye', 'broken'], + 'energetic': ['rock', 'power', 'energy', 'loud', 'fast'], + 'calm': ['peace', 'quiet', 'soft', 'gentle', 'acoustic'], + 'dark': ['dark', 'death', 'black', 'night', 'shadow'] + } + + for mood, keywords in mood_keywords.items(): + if any(keyword in title_lower or keyword in artist_lower for keyword in keywords): + return mood + + return "neutral" + + def calculate_quality_score(self, metadata: Any) -> float: + """Calculate metadata quality score""" + score = 0.0 + + # Check for ISRC (high quality indicator) + if getattr(metadata, 'isrc', None): + score += 0.3 + + # Check for release date + if getattr(metadata, 'release_date', None): + score += 0.2 + + # Check for genre information + if getattr(metadata, 'genre', None): + score += 0.2 + + # Check for cover art + if getattr(metadata, 'image_url', None): + score += 0.1 + + # Check for duration + if getattr(metadata, 'duration_ms', None): + score += 0.1 + + # Check for extended metadata + if getattr(metadata, 'metadata', None): + score += 0.1 + + return min(score, 1.0) + + +# Global instances +metadata_aggregator = MetadataAggregator() +free_enricher = FreeMetadataEnricher() diff --git a/src/swingmusic/services/mobile_offline_service.py b/src/swingmusic/services/mobile_offline_service.py new file mode 100644 index 00000000..2f374ec9 --- /dev/null +++ b/src/swingmusic/services/mobile_offline_service.py @@ -0,0 +1,732 @@ +""" +Mobile Offline Mode Service + +This service provides comprehensive mobile offline functionality including: +- Mobile download manager with intelligent queuing +- Offline sync service with conflict resolution +- Offline playback with adaptive streaming +- Storage management and optimization +- Background sync and progress tracking +""" + +import asyncio +import datetime +import json +import logging +import os +from typing import Dict, List, Optional, Tuple, Any +from dataclasses import dataclass, asdict +from enum import Enum +from pathlib import Path +import hashlib + +from sqlalchemy import select, update, delete, and_, or_, func +from sqlalchemy.orm import Session + +from swingmusic.db import db +from swingmusic.models.user import User +from swingmusic.models.track import Track +from swingmusic.models.playlist import Playlist +from swingmusic.services.universal_music_downloader import UniversalMusicDownloader +from swingmusic.services.audio_quality_manager import audio_quality_manager +from swingmusic.utils.storage_manager import StorageManager +from swingmusic.utils.background_sync import BackgroundSyncManager +from swingmusic.config import USER_DATA_DIR + +logger = logging.getLogger(__name__) + + +class SyncStatus(Enum): + """Sync status for mobile devices""" + NOT_SYNCED = "not_synced" + SYNCING = "syncing" + SYNCED = "synced" + SYNC_ERROR = "sync_error" + CONFLICT = "conflict" + + +class OfflineQuality(Enum): + """Offline download quality presets""" + SPACE_SAVER = "space_saver" # Low quality, maximum storage efficiency + BALANCED = "balanced" # Medium quality, good balance + HIGH_QUALITY = "high_quality" # High quality, more storage usage + LOSSLESS = "lossless" # Lossless quality, maximum storage usage + + +@dataclass +class MobileDevice: + """Represents a mobile device registered for offline sync""" + device_id: str + user_id: int + device_name: str + device_type: str # android, ios + storage_capacity: int # in bytes + available_storage: int # in bytes + last_sync: Optional[datetime.datetime] + sync_status: SyncStatus + offline_quality: OfflineQuality + auto_sync_enabled: bool + sync_preferences: Dict[str, Any] + created_at: datetime.datetime + updated_at: datetime.datetime + + +@dataclass +class OfflineTrack: + """Track available for offline playback""" + track_id: str + device_id: str + user_id: int + local_path: str + file_size: int + quality: str + download_date: datetime.datetime + last_played: Optional[datetime.datetime] + play_count: int + sync_version: int + checksum: str + is_available: bool + + +@dataclass +class SyncQueue: + """Item in the sync queue for mobile devices""" + queue_id: str + device_id: str + track_id: str + user_id: int + priority: int # 1=highest, 5=lowest + quality: str + status: str # pending, downloading, completed, failed + progress: float # 0-100 + error_message: Optional[str] + added_at: datetime.datetime + started_at: Optional[datetime.datetime] + completed_at: Optional[datetime.datetime] + + +@dataclass +class StorageUsage: + """Storage usage information""" + total_capacity: int + used_space: int + available_space: int + offline_tracks_count: int + offline_tracks_size: int + other_data_size: int + quality_breakdown: Dict[str, int] + + +class MobileOfflineService: + """Service for managing mobile offline functionality""" + + def __init__(self): + self.storage_manager = StorageManager() + self.background_sync = BackgroundSyncManager() + self.universal_downloader = UniversalMusicDownloader() + self.mobile_data_dir = USER_DATA_DIR / "mobile" + self.mobile_data_dir.mkdir(exist_ok=True) + + # Device settings + self.max_concurrent_downloads = 3 + self.default_offline_quality = OfflineQuality.BALANCED + self.auto_cleanup_threshold = 0.9 # 90% storage usage triggers cleanup + + async def register_device(self, user_id: int, device_info: Dict[str, Any]) -> MobileDevice: + """ + Register a new mobile device for offline sync + + Args: + user_id: User ID + device_info: Device information including name, type, storage + + Returns: + Registered device information + """ + try: + device_id = self._generate_device_id(user_id, device_info) + + device = MobileDevice( + device_id=device_id, + user_id=user_id, + device_name=device_info.get('name', 'Unknown Device'), + device_type=device_info.get('type', 'unknown'), + storage_capacity=device_info.get('storage_capacity', 0), + available_storage=device_info.get('available_storage', 0), + last_sync=None, + sync_status=SyncStatus.NOT_SYNCED, + offline_quality=self.default_offline_quality, + auto_sync_enabled=True, + sync_preferences=device_info.get('preferences', {}), + created_at=datetime.datetime.utcnow(), + updated_at=datetime.datetime.utcnow() + ) + + # Save device to database + await self._save_device(device) + + # Initialize device storage + await self._initialize_device_storage(device) + + logger.info(f"Registered mobile device {device_id} for user {user_id}") + return device + + except Exception as e: + logger.error(f"Error registering mobile device: {e}") + raise + + async def add_to_offline_library(self, user_id: int, device_id: str, track_ids: List[str], + quality: Optional[OfflineQuality] = None) -> List[SyncQueue]: + """ + Add tracks to offline library for mobile device + + Args: + user_id: User ID + device_id: Device ID + track_ids: List of track IDs to download + quality: Download quality (uses device default if None) + + Returns: + List of sync queue items + """ + try: + # Get device information + device = await self._get_device(device_id, user_id) + if not device: + raise ValueError(f"Device {device_id} not found for user {user_id}") + + # Use device quality if not specified + if quality is None: + quality = device.offline_quality + + # Check storage availability + storage_usage = await self._get_storage_usage(device_id) + required_space = await self._estimate_download_size(track_ids, quality) + + if storage_usage.available_space < required_space: + # Try to cleanup space + freed_space = await self._cleanup_old_content(device_id, required_space) + if freed_space < required_space: + raise ValueError(f"Insufficient storage space. Need {required_space} bytes, only {storage_usage.available_space} available") + + # Add tracks to sync queue + queue_items = [] + for track_id in track_ids: + # Check if already downloaded + existing = await self._get_offline_track(device_id, track_id) + if existing and existing.is_available: + continue + + # Create queue item + queue_item = SyncQueue( + queue_id=self._generate_queue_id(), + device_id=device_id, + track_id=track_id, + user_id=user_id, + priority=self._calculate_download_priority(track_id, user_id), + quality=quality.value, + status='pending', + progress=0.0, + error_message=None, + added_at=datetime.datetime.utcnow(), + started_at=None, + completed_at=None + ) + + await self._add_to_sync_queue(queue_item) + queue_items.append(queue_item) + + # Start background processing if not already running + await self._start_background_sync(device_id) + + logger.info(f"Added {len(queue_items)} tracks to offline library for device {device_id}") + return queue_items + + except Exception as e: + logger.error(f"Error adding tracks to offline library: {e}") + raise + + async def sync_playlist_offline(self, user_id: int, device_id: str, playlist_id: str, + quality: Optional[OfflineQuality] = None) -> List[SyncQueue]: + """ + Sync entire playlist for offline playback + + Args: + user_id: User ID + device_id: Device ID + playlist_id: Playlist ID to sync + quality: Download quality + + Returns: + List of sync queue items + """ + try: + # Get playlist tracks + playlist_tracks = await self._get_playlist_tracks(user_id, playlist_id) + track_ids = [track['id'] for track in playlist_tracks] + + # Add to offline library + return await self.add_to_offline_library(user_id, device_id, track_ids, quality) + + except Exception as e: + logger.error(f"Error syncing playlist offline: {e}") + raise + + async def get_offline_library(self, user_id: int, device_id: str) -> Dict[str, Any]: + """ + Get offline library for mobile device + + Args: + user_id: User ID + device_id: Device ID + + Returns: + Offline library information + """ + try: + # Get device information + device = await self._get_device(device_id, user_id) + if not device: + raise ValueError(f"Device {device_id} not found for user {user_id}") + + # Get offline tracks + offline_tracks = await self._get_offline_tracks(device_id) + + # Get sync queue status + queue_status = await self._get_sync_queue_status(device_id) + + # Get storage usage + storage_usage = await self._get_storage_usage(device_id) + + return { + 'device': asdict(device), + 'offline_tracks': [asdict(track) for track in offline_tracks], + 'sync_queue': { + 'pending_count': queue_status['pending'], + 'downloading_count': queue_status['downloading'], + 'completed_count': queue_status['completed'], + 'failed_count': queue_status['failed'], + 'total_count': queue_status['total'] + }, + 'storage_usage': asdict(storage_usage), + 'last_sync': device.last_sync, + 'sync_status': device.sync_status.value + } + + except Exception as e: + logger.error(f"Error getting offline library: {e}") + raise + + async def remove_from_offline_library(self, user_id: int, device_id: str, track_ids: List[str]) -> bool: + """ + Remove tracks from offline library + + Args: + user_id: User ID + device_id: Device ID + track_ids: List of track IDs to remove + + Returns: + Success status + """ + try: + removed_count = 0 + + for track_id in track_ids: + # Get offline track + offline_track = await self._get_offline_track(device_id, track_id) + if not offline_track: + continue + + # Remove local file + if os.path.exists(offline_track.local_path): + os.remove(offline_track.local_path) + + # Remove from database + await self._remove_offline_track(device_id, track_id) + removed_count += 1 + + # Update storage usage + await self._update_storage_usage(device_id) + + logger.info(f"Removed {removed_count} tracks from offline library for device {device_id}") + return True + + except Exception as e: + logger.error(f"Error removing tracks from offline library: {e}") + return False + + async def update_device_settings(self, user_id: int, device_id: str, settings: Dict[str, Any]) -> bool: + """ + Update device settings and preferences + + Args: + user_id: User ID + device_id: Device ID + settings: Settings to update + + Returns: + Success status + """ + try: + device = await self._get_device(device_id, user_id) + if not device: + return False + + # Update settings + if 'offline_quality' in settings: + device.offline_quality = OfflineQuality(settings['offline_quality']) + + if 'auto_sync_enabled' in settings: + device.auto_sync_enabled = settings['auto_sync_enabled'] + + if 'sync_preferences' in settings: + device.sync_preferences.update(settings['sync_preferences']) + + if 'storage_capacity' in settings: + device.storage_capacity = settings['storage_capacity'] + + if 'available_storage' in settings: + device.available_storage = settings['available_storage'] + + device.updated_at = datetime.datetime.utcnow() + + # Save updated device + await self._save_device(device) + + logger.info(f"Updated settings for device {device_id}") + return True + + except Exception as e: + logger.error(f"Error updating device settings: {e}") + return False + + async def get_sync_progress(self, user_id: int, device_id: str) -> Dict[str, Any]: + """ + Get sync progress for mobile device + + Args: + user_id: User ID + device_id: Device ID + + Returns: + Sync progress information + """ + try: + # Get queue items + queue_items = await self._get_sync_queue_items(device_id) + + # Calculate progress + total_items = len(queue_items) + completed_items = len([item for item in queue_items if item.status == 'completed']) + downloading_items = len([item for item in queue_items if item.status == 'downloading']) + failed_items = len([item for item in queue_items if item.status == 'failed']) + + # Calculate overall progress + overall_progress = 0.0 + if total_items > 0: + total_progress = sum(item.progress for item in queue_items) + overall_progress = total_progress / total_items + + # Get currently downloading items + current_downloads = [item for item in queue_items if item.status == 'downloading'] + + return { + 'total_items': total_items, + 'completed_items': completed_items, + 'downloading_items': downloading_items, + 'failed_items': failed_items, + 'overall_progress': round(overall_progress, 2), + 'current_downloads': [ + { + 'track_id': item.track_id, + 'progress': item.progress, + 'quality': item.quality, + 'added_at': item.added_at.isoformat() + } + for item in current_downloads + ], + 'estimated_time_remaining': await self._estimate_sync_time_remaining(device_id) + } + + except Exception as e: + logger.error(f"Error getting sync progress: {e}") + raise + + async def force_sync_now(self, user_id: int, device_id: str) -> bool: + """ + Force immediate sync for mobile device + + Args: + user_id: User ID + device_id: Device ID + + Returns: + Success status + """ + try: + device = await self._get_device(device_id, user_id) + if not device: + return False + + # Update sync status + device.sync_status = SyncStatus.SYNCING + device.last_sync = datetime.datetime.utcnow() + await self._save_device(device) + + # Start background sync + await self._start_background_sync(device_id, force=True) + + logger.info(f"Force sync started for device {device_id}") + return True + + except Exception as e: + logger.error(f"Error forcing sync: {e}") + return False + + # Private helper methods + + def _generate_device_id(self, user_id: int, device_info: Dict[str, Any]) -> str: + """Generate unique device ID""" + device_string = f"{user_id}_{device_info.get('type', 'unknown')}_{device_info.get('name', 'unknown')}" + return hashlib.sha256(device_string.encode()).hexdigest()[:16] + + def _generate_queue_id(self) -> str: + """Generate unique queue ID""" + return hashlib.sha256(f"{datetime.datetime.utcnow().isoformat()}".encode()).hexdigest()[:16] + + async def _save_device(self, device: MobileDevice): + """Save device to database""" + # This would save to database - simplified for now + device_file = self.mobile_data_dir / f"device_{device.device_id}.json" + with open(device_file, 'w') as f: + json.dump(asdict(device), f, default=str) + + async def _get_device(self, device_id: str, user_id: int) -> Optional[MobileDevice]: + """Get device from database""" + device_file = self.mobile_data_dir / f"device_{device_id}.json" + if not device_file.exists(): + return None + + with open(device_file, 'r') as f: + device_data = json.load(f) + + if device_data['user_id'] != user_id: + return None + + return MobileDevice(**device_data) + + async def _initialize_device_storage(self, device: MobileDevice): + """Initialize storage for device""" + device_storage = self.mobile_data_dir / device.device_id + device_storage.mkdir(exist_ok=True) + + # Create subdirectories + (device_storage / "tracks").mkdir(exist_ok=True) + (device_storage / "metadata").mkdir(exist_ok=True) + (device_storage / "cache").mkdir(exist_ok=True) + + async def _get_storage_usage(self, device_id: str) -> StorageUsage: + """Get storage usage information""" + device_storage = self.mobile_data_dir / device_id + + if not device_storage.exists(): + return StorageUsage(0, 0, 0, 0, 0, 0, {}) + + # Calculate directory sizes + total_size = 0 + tracks_size = 0 + tracks_count = 0 + + for file_path in device_storage.rglob("*"): + if file_path.is_file(): + file_size = file_path.stat().st_size + total_size += file_size + + if file_path.parent.name == "tracks": + tracks_size += file_size + tracks_count += 1 + + # Get device capacity (this would come from device info) + device = await self._get_device(device_id, None) # user_id not needed for this + + return StorageUsage( + total_capacity=device.storage_capacity if device else 0, + used_space=total_size, + available_space=device.available_storage if device else 0, + offline_tracks_count=tracks_count, + offline_tracks_size=tracks_size, + other_data_size=total_size - tracks_size, + quality_breakdown={} # Would calculate by quality + ) + + async def _estimate_download_size(self, track_ids: List[str], quality: OfflineQuality) -> int: + """Estimate download size for tracks""" + # Simplified estimation - would use actual track metadata + quality_sizes = { + OfflineQuality.SPACE_SAVER: 3 * 1024 * 1024, # 3MB per track + OfflineQuality.BALANCED: 6 * 1024 * 1024, # 6MB per track + OfflineQuality.HIGH_QUALITY: 12 * 1024 * 1024, # 12MB per track + OfflineQuality.LOSSLESS: 30 * 1024 * 1024, # 30MB per track + } + + return len(track_ids) * quality_sizes.get(quality, quality_sizes[OfflineQuality.BALANCED]) + + async def _cleanup_old_content(self, device_id: str, required_space: int) -> int: + """Cleanup old content to free space""" + # Get offline tracks sorted by last played + offline_tracks = await self._get_offline_tracks(device_id) + + # Sort by last played (oldest first) + offline_tracks.sort(key=lambda t: t.last_played or datetime.datetime.min) + + freed_space = 0 + for track in offline_tracks: + if freed_space >= required_space: + break + + # Remove track + if os.path.exists(track.local_path): + file_size = os.path.getsize(track.local_path) + os.remove(track.local_path) + freed_space += file_size + + # Mark as unavailable + track.is_available = False + await self._save_offline_track(track) + + return freed_space + + async def _add_to_sync_queue(self, queue_item: SyncQueue): + """Add item to sync queue""" + queue_file = self.mobile_data_dir / f"queue_{queue_item.device_id}.json" + + # Load existing queue + queue = [] + if queue_file.exists(): + with open(queue_file, 'r') as f: + queue = json.load(f) + + # Add new item + queue.append(asdict(queue_item)) + + # Save queue + with open(queue_file, 'w') as f: + json.dump(queue, f, default=str) + + async def _get_sync_queue_items(self, device_id: str) -> List[SyncQueue]: + """Get all sync queue items for device""" + queue_file = self.mobile_data_dir / f"queue_{device_id}.json" + if not queue_file.exists(): + return [] + + with open(queue_file, 'r') as f: + queue_data = json.load(f) + + return [SyncQueue(**item) for item in queue_data] + + async def _calculate_download_priority(self, track_id: str, user_id: int) -> int: + """Calculate download priority for track""" + # This would consider factors like: + # - User's favorite tracks + # - Recently played tracks + # - Playlist membership + # - User preferences + + # Simplified for now + return 3 # Medium priority + + async def _start_background_sync(self, device_id: str, force: bool = False): + """Start background sync process""" + # This would integrate with BackgroundSyncManager + # For now, just log the request + logger.info(f"Background sync requested for device {device_id}, force={force}") + + async def _get_offline_track(self, device_id: str, track_id: str) -> Optional[OfflineTrack]: + """Get offline track information""" + tracks_dir = self.mobile_data_dir / device_id / "metadata" + track_file = tracks_dir / f"{track_id}.json" + + if not track_file.exists(): + return None + + with open(track_file, 'r') as f: + track_data = json.load(f) + + return OfflineTrack(**track_data) + + async def _save_offline_track(self, track: OfflineTrack): + """Save offline track information""" + tracks_dir = self.mobile_data_dir / track.device_id / "metadata" + tracks_dir.mkdir(exist_ok=True) + + track_file = tracks_dir / f"{track.track_id}.json" + with open(track_file, 'w') as f: + json.dump(asdict(track), f, default=str) + + async def _get_offline_tracks(self, device_id: str) -> List[OfflineTrack]: + """Get all offline tracks for device""" + tracks_dir = self.mobile_data_dir / device_id / "metadata" + if not tracks_dir.exists(): + return [] + + tracks = [] + for track_file in tracks_dir.glob("*.json"): + with open(track_file, 'r') as f: + track_data = json.load(f) + tracks.append(OfflineTrack(**track_data)) + + return tracks + + async def _remove_offline_track(self, device_id: str, track_id: str): + """Remove offline track from database""" + tracks_dir = self.mobile_data_dir / device_id / "metadata" + track_file = tracks_dir / f"{track_id}.json" + + if track_file.exists(): + track_file.unlink() + + async def _get_sync_queue_status(self, device_id: str) -> Dict[str, int]: + """Get sync queue status summary""" + queue_items = await self._get_sync_queue_items(device_id) + + status_counts = { + 'pending': 0, + 'downloading': 0, + 'completed': 0, + 'failed': 0, + 'total': len(queue_items) + } + + for item in queue_items: + status_counts[item.status] = status_counts.get(item.status, 0) + 1 + + return status_counts + + async def _update_storage_usage(self, device_id: str): + """Update storage usage information""" + # This would update device storage information + # For now, just recalculate + await self._get_storage_usage(device_id) + + async def _get_playlist_tracks(self, user_id: int, playlist_id: str) -> List[Dict[str, Any]]: + """Get tracks in playlist""" + # This would query the database for playlist tracks + # Simplified for now + return [] + + async def _estimate_sync_time_remaining(self, device_id: str) -> Optional[int]: + """Estimate time remaining for sync completion""" + queue_items = await self._get_sync_queue_items(device_id) + pending_items = [item for item in queue_items if item.status in ['pending', 'downloading']] + + if not pending_items: + return None + + # Estimate based on average download time + avg_time_per_track = 30 # seconds + return len(pending_items) * avg_time_per_track + + +# Global service instance +mobile_offline_service = MobileOfflineService() diff --git a/src/swingmusic/services/music_catalog.py b/src/swingmusic/services/music_catalog.py new file mode 100644 index 00000000..a9c3a5e0 --- /dev/null +++ b/src/swingmusic/services/music_catalog.py @@ -0,0 +1,904 @@ +""" +Music Catalog Service for SwingMusic +Provides Spotify-like browsing of global music catalog with download capabilities +""" + +import os +import json +import time +import asyncio +import aiohttp +from typing import Dict, List, Optional, Tuple, Any +from dataclasses import dataclass, asdict +from enum import Enum +from datetime import datetime, timedelta + +from swingmusic import logger +from swingmusic.db.sqlite.utils import get_db_connection + + +class CatalogItemType(Enum): + TRACK = "track" + ALBUM = "album" + ARTIST = "artist" + PLAYLIST = "playlist" + + +@dataclass +class CatalogItem: + """Represents an item in the global music catalog""" + spotify_id: str + item_type: CatalogItemType + title: str + artist: str + album: Optional[str] = None + duration_ms: Optional[int] = None + popularity: Optional[int] = None + preview_url: Optional[str] = None + image_url: Optional[str] = None + release_date: Optional[str] = None + explicit: bool = False + data: Optional[Dict[str, Any]] = None + cached_at: Optional[datetime] = None + expires_at: Optional[datetime] = None + + +@dataclass +class ArtistInfo: + """Extended artist information with top tracks""" + spotify_id: str + name: str + image_url: Optional[str] = None + followers: Optional[int] = None + popularity: Optional[int] = None + genres: Optional[List[str]] = None + top_tracks: Optional[List[CatalogItem]] = None + albums: Optional[List[CatalogItem]] = None + related_artists: Optional[List[Dict]] = None + + +@dataclass +class SearchResult: + """Global search result across all content types""" + tracks: List[CatalogItem] + albums: List[CatalogItem] + artists: List[CatalogItem] + playlists: List[CatalogItem] + total: int + query: str + + +class MusicCatalogService: + """Service for managing global music catalog with caching""" + + def __init__(self): + self.cache_ttl = 3600 # 1 hour default cache TTL + self.max_top_tracks = 15 + self.max_albums_per_artist = 20 + self.session = None + + async def _get_session(self): + """Get or create aiohttp session""" + if self.session is None: + self.session = aiohttp.ClientSession() + return self.session + + async def close(self): + """Close aiohttp session""" + if self.session: + await self.session.close() + + def _get_spotify_client(self): + """Get Spotify metadata client""" + try: + from swingmusic.services.spotify_metadata_client import spotify_metadata_client + return spotify_metadata_client + except ImportError: + logger.warning("Spotify metadata client not available for catalog service") + return None + + async def get_artist_top_tracks(self, artist_id: str, limit: int = 15) -> List[CatalogItem]: + """ + Get artist's most popular tracks + + Args: + artist_id: Spotify artist ID + limit: Maximum number of tracks to return + + Returns: + List of popular tracks + """ + try: + # Check cache first + cached_tracks = await self._get_cached_artist_top_tracks(artist_id, limit) + if cached_tracks: + return cached_tracks + + # Fetch from Spotify API + spotify_client = self._get_spotify_client() + if not spotify_client: + return [] + + # This would integrate with the existing Spotify metadata client + # For now, return empty list - integration point + tracks_data = await self._fetch_artist_top_tracks_from_spotify(artist_id, limit) + + # Cache the results + await self._cache_artist_top_tracks(artist_id, tracks_data) + + return tracks_data + + except Exception as e: + logger.error(f"Error getting artist top tracks: {e}") + return [] + + async def get_artist_discography(self, artist_id: str) -> List[CatalogItem]: + """ + Get complete artist discography with albums + + Args: + artist_id: Spotify artist ID + + Returns: + List of artist albums + """ + try: + # Check cache first + cached_albums = await self._get_cached_artist_albums(artist_id) + if cached_albums: + return cached_albums + + # Fetch from Spotify API + spotify_client = self._get_spotify_client() + if not spotify_client: + return [] + + albums_data = await self._fetch_artist_albums_from_spotify(artist_id) + + # Cache the results + await self._cache_artist_albums(artist_id, albums_data) + + return albums_data + + except Exception as e: + logger.error(f"Error getting artist discography: {e}") + return [] + + async def get_album_details(self, album_id: str) -> Optional[CatalogItem]: + """ + Get full album information with tracklist + + Args: + album_id: Spotify album ID + + Returns: + Album details with tracklist + """ + try: + # Check cache first + cached_album = await self._get_cached_album(album_id) + if cached_album: + return cached_album + + # Fetch from Spotify API + spotify_client = self._get_spotify_client() + if not spotify_client: + return None + + album_data = await self._fetch_album_details_from_spotify(album_id) + + # Cache the result + await self._cache_album(album_id, album_data) + + return album_data + + except Exception as e: + logger.error(f"Error getting album details: {e}") + return None + + async def search_global_catalog(self, query: str, item_type: str = "all", limit: int = 20) -> SearchResult: + """ + Search across all music types in global catalog + + Args: + query: Search query + item_type: Type of content to search (all, tracks, albums, artists, playlists) + limit: Maximum results per type + + Returns: + Search results across specified types + """ + try: + # Check cache first + cache_key = f"search:{query}:{item_type}:{limit}" + cached_result = await self._get_cached_search(cache_key) + if cached_result: + return cached_result + + # Search different types based on request + tracks = [] + albums = [] + artists = [] + playlists = [] + + spotify_client = self._get_spotify_client() + if spotify_client: + if item_type in ["all", "tracks"]: + tracks = await self._search_tracks(query, limit) + if item_type in ["all", "albums"]: + albums = await self._search_albums(query, limit) + if item_type in ["all", "artists"]: + artists = await self._search_artists(query, limit) + if item_type in ["all", "playlists"]: + playlists = await self._search_playlists(query, limit) + + result = SearchResult( + tracks=tracks, + albums=albums, + artists=artists, + playlists=playlists, + total=len(tracks) + len(albums) + len(artists) + len(playlists), + query=query + ) + + # Cache the search result + await self._cache_search(cache_key, result) + + return result + + except Exception as e: + logger.error(f"Error searching global catalog: {e}") + return SearchResult([], [], [], [], 0, query) + + async def get_artist_info(self, artist_id: str) -> Optional[ArtistInfo]: + """ + Get comprehensive artist information including top tracks and albums + + Args: + artist_id: Spotify artist ID + + Returns: + Complete artist information + """ + try: + # Check cache first + cached_info = await self._get_cached_artist_info(artist_id) + if cached_info: + return cached_info + + # Fetch all artist data concurrently + top_tracks_task = self.get_artist_top_tracks(artist_id, self.max_top_tracks) + albums_task = self.get_artist_discography(artist_id) + basic_info_task = self._get_artist_basic_info(artist_id) + + top_tracks, albums, basic_info = await asyncio.gather( + top_tracks_task, albums_task, basic_info_task, return_exceptions=True + ) + + if isinstance(basic_info, Exception): + logger.error(f"Error getting basic artist info: {basic_info}") + return None + + artist_info = ArtistInfo( + spotify_id=artist_id, + name=basic_info.get("name", ""), + image_url=basic_info.get("image_url"), + followers=basic_info.get("followers"), + popularity=basic_info.get("popularity"), + genres=basic_info.get("genres", []), + top_tracks=top_tracks if not isinstance(top_tracks, Exception) else [], + albums=albums if not isinstance(albums, Exception) else [], + related_artists=basic_info.get("related_artists", []) + ) + + # Cache the complete artist info + await self._cache_artist_info(artist_id, artist_info) + + return artist_info + + except Exception as e: + logger.error(f"Error getting artist info: {e}") + return None + + # Cache management methods + async def _get_cached_artist_top_tracks(self, artist_id: str, limit: int) -> Optional[List[CatalogItem]]: + """Get cached top tracks for artist""" + try: + with get_db_connection() as conn: + query = """ + SELECT data FROM global_catalog_cache + WHERE spotify_id = ? AND item_type = 'artist_top_tracks' + AND expires_at > datetime('now') + ORDER BY cached_at DESC LIMIT 1 + """ + cursor = conn.execute(query, (artist_id,)) + row = cursor.fetchone() + + if row: + data = json.loads(row[0]) + return [CatalogItem(**item) for item in data.get('tracks', [])[:limit]] + + except Exception as e: + logger.error(f"Error getting cached artist top tracks: {e}") + + return None + + async def _cache_artist_top_tracks(self, artist_id: str, tracks: List[CatalogItem]): + """Cache artist top tracks""" + try: + expires_at = datetime.now() + timedelta(seconds=self.cache_ttl) + + with get_db_connection() as conn: + conn.execute(""" + INSERT OR REPLACE INTO global_catalog_cache + (spotify_id, item_type, title, artist, data, cached_at, expires_at) + VALUES (?, 'artist_top_tracks', ?, ?, ?, datetime('now'), ?) + """, ( + artist_id, + f"Top tracks for {artist_id}", + "", + json.dumps({"tracks": [asdict(track) for track in tracks]}), + expires_at.isoformat() + )) + conn.commit() + + except Exception as e: + logger.error(f"Error caching artist top tracks: {e}") + + async def _get_cached_artist_albums(self, artist_id: str) -> Optional[List[CatalogItem]]: + """Get cached albums for artist""" + try: + with get_db_connection() as conn: + query = """ + SELECT data FROM global_catalog_cache + WHERE spotify_id = ? AND item_type = 'artist_albums' + AND expires_at > datetime('now') + ORDER BY cached_at DESC LIMIT 1 + """ + cursor = conn.execute(query, (artist_id,)) + row = cursor.fetchone() + + if row: + data = json.loads(row[0]) + return [CatalogItem(**item) for item in data.get('albums', [])] + + except Exception as e: + logger.error(f"Error getting cached artist albums: {e}") + + return None + + async def _cache_artist_albums(self, artist_id: str, albums: List[CatalogItem]): + """Cache artist albums""" + try: + expires_at = datetime.now() + timedelta(seconds=self.cache_ttl) + + with get_db_connection() as conn: + conn.execute(""" + INSERT OR REPLACE INTO global_catalog_cache + (spotify_id, item_type, title, artist, data, cached_at, expires_at) + VALUES (?, 'artist_albums', ?, ?, ?, datetime('now'), ?) + """, ( + artist_id, + f"Albums for {artist_id}", + "", + json.dumps({"albums": [asdict(album) for album in albums]}), + expires_at.isoformat() + )) + conn.commit() + + except Exception as e: + logger.error(f"Error caching artist albums: {e}") + + async def _get_cached_album(self, album_id: str) -> Optional[CatalogItem]: + """Get cached album details""" + try: + with get_db_connection() as conn: + query = """ + SELECT * FROM global_catalog_cache + WHERE spotify_id = ? AND item_type = 'album' + AND expires_at > datetime('now') + ORDER BY cached_at DESC LIMIT 1 + """ + cursor = conn.execute(query, (album_id,)) + row = cursor.fetchone() + + if row: + return CatalogItem( + spotify_id=row[1], + item_type=CatalogItemType(row[2]), + title=row[3], + artist=row[4], + album=row[5], + duration_ms=row[6], + popularity=row[7], + preview_url=row[8], + image_url=row[9], + release_date=row[10], + explicit=bool(row[11]), + data=json.loads(row[12]) if row[12] else None + ) + + except Exception as e: + logger.error(f"Error getting cached album: {e}") + + return None + + async def _cache_album(self, album_id: str, album: CatalogItem): + """Cache album details""" + try: + expires_at = datetime.now() + timedelta(seconds=self.cache_ttl) + + with get_db_connection() as conn: + conn.execute(""" + INSERT OR REPLACE INTO global_catalog_cache + (spotify_id, item_type, title, artist, album, duration_ms, + popularity, preview_url, image_url, release_date, explicit, data, cached_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?) + """, ( + album.spotify_id, + album.item_type.value, + album.title, + album.artist, + album.album, + album.duration_ms, + album.popularity, + album.preview_url, + album.image_url, + album.release_date, + album.explicit, + json.dumps(asdict(album)) if album.data else None, + expires_at.isoformat() + )) + conn.commit() + + except Exception as e: + logger.error(f"Error caching album: {e}") + + async def _get_cached_search(self, cache_key: str) -> Optional[SearchResult]: + """Get cached search results""" + try: + with get_db_connection() as conn: + query = """ + SELECT data FROM global_catalog_cache + WHERE spotify_id = ? AND item_type = 'search' + AND expires_at > datetime('now') + ORDER BY cached_at DESC LIMIT 1 + """ + cursor = conn.execute(query, (cache_key,)) + row = cursor.fetchone() + + if row: + data = json.loads(row[0]) + return SearchResult( + tracks=[CatalogItem(**item) for item in data.get('tracks', [])], + albums=[CatalogItem(**item) for item in data.get('albums', [])], + artists=[CatalogItem(**item) for item in data.get('artists', [])], + playlists=[CatalogItem(**item) for item in data.get('playlists', [])], + total=data.get('total', 0), + query=data.get('query', '') + ) + + except Exception as e: + logger.error(f"Error getting cached search: {e}") + + return None + + async def _cache_search(self, cache_key: str, result: SearchResult): + """Cache search results""" + try: + expires_at = datetime.now() + timedelta(seconds=self.cache_ttl // 2) # Shorter cache for searches + + with get_db_connection() as conn: + conn.execute(""" + INSERT OR REPLACE INTO global_catalog_cache + (spotify_id, item_type, title, artist, data, cached_at, expires_at) + VALUES (?, 'search', ?, ?, ?, datetime('now'), ?) + """, ( + cache_key, + f"Search: {result.query}", + "", + json.dumps({ + 'tracks': [asdict(track) for track in result.tracks], + 'albums': [asdict(album) for album in result.albums], + 'artists': [asdict(artist) for artist in result.artists], + 'playlists': [asdict(playlist) for playlist in result.playlists], + 'total': result.total, + 'query': result.query + }), + expires_at.isoformat() + )) + conn.commit() + + except Exception as e: + logger.error(f"Error caching search: {e}") + + async def _get_cached_artist_info(self, artist_id: str) -> Optional[ArtistInfo]: + """Get cached complete artist info""" + try: + with get_db_connection() as conn: + query = """ + SELECT data FROM global_catalog_cache + WHERE spotify_id = ? AND item_type = 'artist_info' + AND expires_at > datetime('now') + ORDER BY cached_at DESC LIMIT 1 + """ + cursor = conn.execute(query, (artist_id,)) + row = cursor.fetchone() + + if row: + data = json.loads(row[0]) + return ArtistInfo( + spotify_id=data['spotify_id'], + name=data['name'], + image_url=data.get('image_url'), + followers=data.get('followers'), + popularity=data.get('popularity'), + genres=data.get('genres', []), + top_tracks=[CatalogItem(**item) for item in data.get('top_tracks', [])], + albums=[CatalogItem(**item) for item in data.get('albums', [])], + related_artists=data.get('related_artists', []) + ) + + except Exception as e: + logger.error(f"Error getting cached artist info: {e}") + + return None + + async def _cache_artist_info(self, artist_id: str, artist_info: ArtistInfo): + """Cache complete artist info""" + try: + expires_at = datetime.now() + timedelta(seconds=self.cache_ttl) + + with get_db_connection() as conn: + conn.execute(""" + INSERT OR REPLACE INTO global_catalog_cache + (spotify_id, item_type, title, artist, data, cached_at, expires_at) + VALUES (?, 'artist_info', ?, ?, ?, datetime('now'), ?) + """, ( + artist_id, + f"Artist info: {artist_info.name}", + "", + json.dumps({ + 'spotify_id': artist_info.spotify_id, + 'name': artist_info.name, + 'image_url': artist_info.image_url, + 'followers': artist_info.followers, + 'popularity': artist_info.popularity, + 'genres': artist_info.genres, + 'top_tracks': [asdict(track) for track in artist_info.top_tracks or []], + 'albums': [asdict(album) for album in artist_info.albums or []], + 'related_artists': artist_info.related_artists or [] + }), + expires_at.isoformat() + )) + conn.commit() + + except Exception as e: + logger.error(f"Error caching artist info: {e}") + + # Spotify API integration methods + async def _fetch_artist_top_tracks_from_spotify(self, artist_id: str, limit: int) -> List[CatalogItem]: + """Fetch artist top tracks from Spotify API""" + try: + spotify_client = self._get_spotify_client() + if not spotify_client: + return [] + + tracks = spotify_client.get_artist_top_tracks(artist_id, market='US') + + catalog_items = [] + for track in tracks[:limit]: + catalog_item = CatalogItem( + spotify_id=track.id, + item_type=CatalogItemType.TRACK, + title=track.name, + artist=', '.join([artist['name'] for artist in track.artists]), + album=track.album['name'] if track.album else None, + duration_ms=track.duration_ms, + popularity=track.popularity, + preview_url=track.preview_url, + image_url=track.album['images'][0]['url'] if track.album and track.album.get('images') else None, + explicit=track.explicit, + data={ + 'artists': track.artists, + 'album': track.album, + 'external_urls': track.external_urls, + 'track_number': track.track_number, + 'disc_number': track.disc_number, + 'available_markets': track.available_markets + } + ) + catalog_items.append(catalog_item) + + return catalog_items + + except Exception as e: + logger.error(f"Error fetching artist top tracks from Spotify: {e}") + return [] + + async def _fetch_artist_albums_from_spotify(self, artist_id: str) -> List[CatalogItem]: + """Fetch artist albums from Spotify API""" + try: + spotify_client = self._get_spotify_client() + if not spotify_client: + return [] + + albums = spotify_client.get_artist_albums(artist_id, limit=self.max_albums_per_artist) + + catalog_items = [] + for album in albums: + catalog_item = CatalogItem( + spotify_id=album.id, + item_type=CatalogItemType.ALBUM, + title=album.name, + artist=', '.join([artist['name'] for artist in album.artists]), + album=album.name, + popularity=album.popularity, + image_url=album.images[0]['url'] if album.images else None, + release_date=album.release_date, + explicit=False, # Albums don't have explicit flag in API + data={ + 'artists': album.artists, + 'total_tracks': album.total_tracks, + 'external_urls': album.external_urls, + 'available_markets': album.available_markets, + 'album_type': album.album_type + } + ) + catalog_items.append(catalog_item) + + return catalog_items + + except Exception as e: + logger.error(f"Error fetching artist albums from Spotify: {e}") + return [] + + async def _fetch_album_details_from_spotify(self, album_id: str) -> Optional[CatalogItem]: + """Fetch album details from Spotify API""" + try: + spotify_client = self._get_spotify_client() + if not spotify_client: + return None + + album = spotify_client.get_album(album_id) + if not album: + return None + + # Get album tracks + tracks = spotify_client.get_album_tracks(album_id) + + catalog_item = CatalogItem( + spotify_id=album.id, + item_type=CatalogItemType.ALBUM, + title=album.name, + artist=', '.join([artist['name'] for artist in album.artists]), + album=album.name, + popularity=album.popularity, + image_url=album.images[0]['url'] if album.images else None, + release_date=album.release_date, + explicit=False, + data={ + 'artists': album.artists, + 'total_tracks': album.total_tracks, + 'external_urls': album.external_urls, + 'available_markets': album.available_markets, + 'album_type': album.album_type, + 'tracks': [ + { + 'id': track.id, + 'name': track.name, + 'artists': track.artists, + 'duration_ms': track.duration_ms, + 'track_number': track.track_number, + 'disc_number': track.disc_number, + 'explicit': track.explicit, + 'preview_url': track.preview_url + } + for track in tracks + ] + } + ) + + return catalog_item + + except Exception as e: + logger.error(f"Error fetching album details from Spotify: {e}") + return None + + async def _search_tracks(self, query: str, limit: int) -> List[CatalogItem]: + """Search tracks in Spotify catalog""" + try: + spotify_client = self._get_spotify_client() + if not spotify_client: + return [] + + search_results = spotify_client.search(query, 'track', limit=limit) + + catalog_items = [] + for track in search_results.get('tracks', []): + catalog_item = CatalogItem( + spotify_id=track.id, + item_type=CatalogItemType.TRACK, + title=track.name, + artist=', '.join([artist['name'] for artist in track.artists]), + album=track.album['name'] if track.album else None, + duration_ms=track.duration_ms, + popularity=track.popularity, + preview_url=track.preview_url, + image_url=track.album['images'][0]['url'] if track.album and track.album.get('images') else None, + explicit=track.explicit, + data={ + 'artists': track.artists, + 'album': track.album, + 'external_urls': track.external_urls, + 'track_number': track.track_number, + 'disc_number': track.disc_number, + 'available_markets': track.available_markets + } + ) + catalog_items.append(catalog_item) + + return catalog_items + + except Exception as e: + logger.error(f"Error searching tracks: {e}") + return [] + + async def _search_albums(self, query: str, limit: int) -> List[CatalogItem]: + """Search albums in Spotify catalog""" + try: + spotify_client = self._get_spotify_client() + if not spotify_client: + return [] + + search_results = spotify_client.search(query, 'album', limit=limit) + + catalog_items = [] + for album in search_results.get('albums', []): + catalog_item = CatalogItem( + spotify_id=album.id, + item_type=CatalogItemType.ALBUM, + title=album.name, + artist=', '.join([artist['name'] for artist in album.artists]), + album=album.name, + popularity=album.popularity, + image_url=album.images[0]['url'] if album.images else None, + release_date=album.release_date, + explicit=False, + data={ + 'artists': album.artists, + 'total_tracks': album.total_tracks, + 'external_urls': album.external_urls, + 'available_markets': album.available_markets, + 'album_type': album.album_type + } + ) + catalog_items.append(catalog_item) + + return catalog_items + + except Exception as e: + logger.error(f"Error searching albums: {e}") + return [] + + async def _search_artists(self, query: str, limit: int) -> List[CatalogItem]: + """Search artists in Spotify catalog""" + try: + spotify_client = self._get_spotify_client() + if not spotify_client: + return [] + + search_results = spotify_client.search(query, 'artist', limit=limit) + + catalog_items = [] + for artist in search_results.get('artists', []): + catalog_item = CatalogItem( + spotify_id=artist.id, + item_type=CatalogItemType.ARTIST, + title=artist.name, + artist=artist.name, + popularity=artist.popularity, + image_url=artist.images[0]['url'] if artist.images else None, + explicit=False, + data={ + 'followers': artist.followers, + 'genres': artist.genres, + 'external_urls': artist.external_urls + } + ) + catalog_items.append(catalog_item) + + return catalog_items + + except Exception as e: + logger.error(f"Error searching artists: {e}") + return [] + + async def _search_playlists(self, query: str, limit: int) -> List[CatalogItem]: + """Search playlists in Spotify catalog""" + try: + spotify_client = self._get_spotify_client() + if not spotify_client: + return [] + + search_results = spotify_client.search(query, 'playlist', limit=limit) + + catalog_items = [] + for playlist in search_results.get('playlists', []): + catalog_item = CatalogItem( + spotify_id=playlist.id, + item_type=CatalogItemType.PLAYLIST, + title=playlist.name, + artist=playlist.owner['display_name'] if playlist.owner else '', + popularity=0, # Playlists don't have popularity + image_url=playlist.images[0]['url'] if playlist.images else None, + explicit=False, + data={ + 'description': playlist.description, + 'owner': playlist.owner, + 'public': playlist.public, + 'collaborative': playlist.collaborative, + 'tracks': playlist.tracks, + 'external_urls': playlist.external_urls + } + ) + catalog_items.append(catalog_item) + + return catalog_items + + except Exception as e: + logger.error(f"Error searching playlists: {e}") + return [] + + async def _get_artist_basic_info(self, artist_id: str) -> Optional[Dict[str, Any]]: + """Get basic artist information from Spotify API""" + try: + spotify_client = self._get_spotify_client() + if not spotify_client: + return None + + artist = spotify_client.get_artist(artist_id) + if not artist: + return None + + # Get related artists + related_artists = spotify_client.get_related_artists(artist_id) + + return { + 'name': artist.name, + 'image_url': artist.images[0]['url'] if artist.images else None, + 'followers': artist.followers.get('total', 0) if artist.followers else 0, + 'popularity': artist.popularity, + 'genres': artist.genres, + 'related_artists': [ + { + 'id': related.id, + 'name': related.name, + 'popularity': related.popularity, + 'image_url': related.images[0]['url'] if related.images else None + } + for related in related_artists[:10] # Limit to 10 related artists + ] + } + + except Exception as e: + logger.error(f"Error getting basic artist info: {e}") + return None + + def cleanup_expired_cache(self): + """Clean up expired cache entries""" + try: + with get_db_connection() as conn: + conn.execute(""" + DELETE FROM global_catalog_cache + WHERE expires_at < datetime('now') + """) + conn.commit() + logger.info("Cleaned up expired catalog cache entries") + + except Exception as e: + logger.error(f"Error cleaning up expired cache: {e}") + + +# Global instance +music_catalog_service = MusicCatalogService() diff --git a/src/swingmusic/services/musicbrainz_client.py b/src/swingmusic/services/musicbrainz_client.py new file mode 100644 index 00000000..d6ad2dd0 --- /dev/null +++ b/src/swingmusic/services/musicbrainz_client.py @@ -0,0 +1,315 @@ +""" +MusicBrainz API v2 Client for Universal Music Downloader +Provides comprehensive music metadata from MusicBrainz database +""" + +import aiohttp +import asyncio +from typing import Dict, List, Optional, Any +from dataclasses import dataclass +import logging + +logger = logging.getLogger(__name__) + + +@dataclass +class MusicBrainzRecording: + """MusicBrainz recording metadata""" + mbid: str + title: str + artist: str + artist_mbid: Optional[str] = None + release: Optional[str] = None + release_mbid: Optional[str] = None + isrc: Optional[str] = None + duration: Optional[int] = None + position: Optional[int] = None + genres: List[str] = None + release_date: Optional[str] = None + country: Optional[str] = None + tags: List[str] = None + cover_art: Optional[str] = None + + +@dataclass +class MusicBrainzArtist: + """MusicBrainz artist metadata""" + mbid: str + name: str + sort_name: Optional[str] = None + disambiguation: Optional[str] = None + country: Optional[str] = None + life_span: Optional[Dict[str, str]] = None + genres: List[str] = None + tags: List[str] = None + rating: Optional[float] = None + + +class MusicBrainzClient: + """MusicBrainz API v2 client""" + + def __init__(self, app_name: str = "SwingMusic", app_version: str = "1.0.0"): + self.base_url = "https://musicbrainz.org/ws/2" + self.app_name = app_name + self.app_version = app_version + self.session = None + + async def _get_session(self) -> aiohttp.ClientSession: + """Get or create aiohttp session""" + if self.session is None: + self.session = aiohttp.ClientSession() + return self.session + + def _build_url(self, endpoint: str, params: Dict[str, str] = None) -> str: + """Build MusicBrainz API URL""" + url = f"{self.base_url}/{endpoint}" + if params: + param_string = "&".join([f"{k}={v}" for k, v in params.items()]) + url += f"?{param_string}" + return url + + async def lookup_recording(self, mbid: str, includes: List[str] = None) -> Optional[MusicBrainzRecording]: + """Lookup detailed recording information""" + try: + session = await self._get_session() + + params = {} + if includes: + params['inc'] = ",".join(includes) + + url = self._build_url(f"recording/{mbid}", params) + + headers = { + 'User-Agent': f'{self.app_name}/{self.app_version}', + 'Accept': 'application/json' + } + + async with session.get(url, headers=headers) as response: + if response.status == 200: + data = await response.json() + return self._parse_recording_response(data) + else: + logger.warning(f"MusicBrainz recording lookup failed: {response.status}") + return None + + except Exception as e: + logger.error(f"Error looking up MusicBrainz recording: {e}") + return None + + async def lookup_artist(self, mbid: str, includes: List[str] = None) -> Optional[MusicBrainzArtist]: + """Lookup detailed artist information""" + try: + session = await self._get_session() + + params = {} + if includes: + params['inc'] = ",".join(includes) + + url = self._build_url(f"artist/{mbid}", params) + + headers = { + 'User-Agent': f'{self.app_name}/{self.app_version}', + 'Accept': 'application/json' + } + + async with session.get(url, headers=headers) as response: + if response.status == 200: + data = await response.json() + return self._parse_artist_response(data) + else: + logger.warning(f"MusicBrainz artist lookup failed: {response.status}") + return None + + except Exception as e: + logger.error(f"Error looking up MusicBrainz artist: {e}") + return None + + async def search_recordings(self, query: str, artist: str = None, limit: int = 25) -> List[MusicBrainzRecording]: + """Search for recordings""" + try: + session = await self._get_session() + + params = { + 'query': f'"{query}"', + 'limit': str(limit) + } + + if artist: + params['artist'] = f'"{artist}"' + + url = self._build_url("recording", params) + + headers = { + 'User-Agent': f'{self.app_name}/{self.app_version}', + 'Accept': 'application/json' + } + + async with session.get(url, headers=headers) as response: + if response.status == 200: + data = await response.json() + return self._parse_recording_list_response(data) + else: + logger.warning(f"MusicBrainz recording search failed: {response.status}") + return [] + + except Exception as e: + logger.error(f"Error searching MusicBrainz recordings: {e}") + return [] + + async def get_artist_releases(self, mbid: str, release_types: List[str] = None) -> List[str]: + """Get all releases for an artist""" + try: + session = await self._get_session() + + params = {} + if release_types: + params['type'] = ",".join(release_types) + + url = self._build_url(f"release", {'artist': mbid, **params}) + + headers = { + 'User-Agent': f'{self.app_name}/{self.app_version}', + 'Accept': 'application/json' + } + + async with session.get(url, headers=headers) as response: + if response.status == 200: + data = await response.json() + releases = data.get('releases', []) + return [release.get('id', '') for release in releases] + else: + logger.warning(f"MusicBrainz artist releases failed: {response.status}") + return [] + + except Exception as e: + logger.error(f"Error getting MusicBrainz artist releases: {e}") + return [] + + def _parse_recording_response(self, data: Dict[str, Any]) -> Optional[MusicBrainzRecording]: + """Parse MusicBrainz recording response""" + try: + recording_data = data.get('recording') + if not recording_data: + return None + + # Extract basic info + title = recording_data.get('title', '') + + # Extract artist info + artist_credit = recording_data.get('artist-credit', []) + artist = artist_credit[0].get('artist', {}).get('name', '') if artist_credit else '' + artist_mbid = artist_credit[0].get('artist', {}).get('id') if artist_credit else None + + # Extract release info + release_list = recording_data.get('release-list', []) + release = release_list[0] if release_list else None + release_title = release.get('title', '') if release else None + release_mbid = release.get('id') if release else None + + # Extract ISRC + isrc_list = recording_data.get('isrc-list', []) + isrc = isrc_list[0] if isrc_list else None + + # Extract duration + duration = recording_data.get('length') + + # Extract tags and genres + tag_list = recording_data.get('tag-list', []) + tags = [tag.get('name', '') for tag in tag_list] + + # Extract release info + release_info = recording_data.get('release', {}) + release_date = release_info.get('date') + country = release_info.get('country') + + # Extract cover art + cover_art = None + if release: + cover_art_archive = release.get('cover-art-archive', []) + if cover_art_archive: + cover_art = cover_art_archive[0].get('image') + + return MusicBrainzRecording( + mbid=data.get('id', ''), + title=title, + artist=artist, + artist_mbid=artist_mbid, + release=release_title, + release_mbid=release_mbid, + isrc=isrc, + duration=duration, + position=recording_data.get('position'), + genres=tags, + release_date=release_date, + country=country, + tags=tags, + cover_art=cover_art + ) + + except Exception as e: + logger.error(f"Error parsing MusicBrainz recording response: {e}") + return None + + def _parse_artist_response(self, data: Dict[str, Any]) -> Optional[MusicBrainzArtist]: + """Parse MusicBrainz artist response""" + try: + artist_data = data.get('artist') + if not artist_data: + return None + + name = artist_data.get('name', '') + sort_name = artist_data.get('sort-name') + disambiguation = artist_data.get('disambiguation') + country = artist_data.get('country') + + # Extract life span + life_span = artist_data.get('life-span') + + # Extract tags and genres + tag_list = artist_data.get('tag-list', []) + tags = [tag.get('name', '') for tag in tag_list] + + # Extract rating + rating = artist_data.get('rating', {}).get('value') + + return MusicBrainzArtist( + mbid=data.get('id', ''), + name=name, + sort_name=sort_name, + disambiguation=disambiguation, + country=country, + life_span=life_span, + genres=tags, + tags=tags, + rating=rating + ) + + except Exception as e: + logger.error(f"Error parsing MusicBrainz artist response: {e}") + return None + + def _parse_recording_list_response(self, data: Dict[str, Any]) -> List[MusicBrainzRecording]: + """Parse MusicBrainz recording list response""" + try: + recordings = [] + recording_list = data.get('recordings', []) + + for recording_data in recording_list: + recording = self._parse_recording_response({'recording': recording_data}) + if recording: + recordings.append(recording) + + return recordings + + except Exception as e: + logger.error(f"Error parsing MusicBrainz recording list: {e}") + return [] + + async def close(self): + """Close the aiohttp session""" + if self.session: + await self.session.close() + + +# Global instance +musicbrainz_client = MusicBrainzClient() diff --git a/src/swingmusic/services/recap_service.py b/src/swingmusic/services/recap_service.py new file mode 100644 index 00000000..7ad96dd8 --- /dev/null +++ b/src/swingmusic/services/recap_service.py @@ -0,0 +1,785 @@ +""" +Year-in-Review Experience Service + +This service provides comprehensive year-in-review generation including: +- Listening statistics and analytics +- Personalized music insights +- Video generation with Remotion +- Social sharing capabilities +- Interactive data visualization +""" + +import asyncio +import datetime +import json +import logging +import os +from typing import Dict, List, Optional, Tuple, Any +from dataclasses import dataclass, asdict +from enum import Enum +from pathlib import Path + +from sqlalchemy import select, func, and_, or_ +from sqlalchemy.orm import Session + +from swingmusic.db import db +from swingmusic.models.user import User +from swingmusic.models.track import Track +from swingmusic.models.playlog import Playlog +from swingmusic.config import USER_DATA_DIR + +logger = logging.getLogger(__name__) + + +class RecapTheme(Enum): + """Available recap themes""" + MODERN = "modern" + RETRO = "retro" + MINIMAL = "minimal" + VIBRANT = "vibrant" + DARK = "dark" + LIGHT = "light" + + +@dataclass +class ListeningStats: + """User listening statistics for a time period""" + total_minutes: int + total_tracks: int + total_artists: int + total_albums: int + unique_tracks: int + average_daily_minutes: float + most_played_track: Optional[Dict] + most_played_artist: Optional[Dict] + most_played_album: Optional[Dict] + top_genres: List[Dict] + listening_streak: int + longest_session: int + favorite_time_of_day: str + discovery_rate: float + repeat_listen_rate: float + + +@dataclass +class MusicPersonality: + """User music personality analysis""" + personality_type: str + traits: List[str] + description: str + diversity_score: float + exploration_score: float + loyalty_score: float + mood_profile: Dict[str, float] + genre_preferences: Dict[str, float] + audio_preferences: Dict[str, Any] + + +@dataclass +class RecapData: + """Complete year-in-review data package""" + user_id: int + year: int + stats: ListeningStats + personality: MusicPersonality + monthly_breakdown: List[Dict] + top_tracks: List[Dict] + top_artists: List[Dict] + top_albums: List[Dict] + discoveries: List[Dict] + milestones: List[Dict] + created_at: datetime.datetime + + +class RecapService: + """Service for generating comprehensive year-in-review experiences""" + + def __init__(self): + self.recap_dir = USER_DATA_DIR / "recaps" + self.recap_dir.mkdir(exist_ok=True) + + async def generate_year_recap(self, user_id: int, year: int) -> RecapData: + """ + Generate comprehensive year-in-review data + + Args: + user_id: User ID + year: Year to generate recap for + + Returns: + Complete recap data + """ + try: + logger.info(f"Generating year recap for user {user_id}, year {year}") + + # Get listening data for the year + start_date = datetime.datetime(year, 1, 1) + end_date = datetime.datetime(year, 12, 31, 23, 59, 59) + + # Generate all components + stats = await self._calculate_listening_stats(user_id, start_date, end_date) + personality = await self._analyze_music_personality(user_id, start_date, end_date) + monthly_breakdown = await self._get_monthly_breakdown(user_id, year) + top_tracks = await self._get_top_tracks(user_id, start_date, end_date, 50) + top_artists = await self._get_top_artists(user_id, start_date, end_date, 25) + top_albums = await self._get_top_albums(user_id, start_date, end_date, 25) + discoveries = await self._get_new_discoveries(user_id, start_date, end_date) + milestones = await self._calculate_milestones(stats, personality) + + recap_data = RecapData( + user_id=user_id, + year=year, + stats=stats, + personality=personality, + monthly_breakdown=monthly_breakdown, + top_tracks=top_tracks, + top_artists=top_artists, + top_albums=top_albums, + discoveries=discoveries, + milestones=milestones, + created_at=datetime.datetime.utcnow() + ) + + # Save recap data + await self._save_recap_data(recap_data) + + return recap_data + + except Exception as e: + logger.error(f"Error generating year recap: {e}") + raise + + async def get_recap_summary(self, user_id: int, year: int) -> Optional[Dict]: + """ + Get recap summary for quick display + + Args: + user_id: User ID + year: Year to get summary for + + Returns: + Recap summary or None if not available + """ + try: + recap_file = self.recap_dir / f"recap_{user_id}_{year}.json" + + if not recap_file.exists(): + return None + + with open(recap_file, 'r') as f: + recap_data = json.load(f) + + # Return summary data + return { + 'year': recap_data['year'], + 'total_minutes': recap_data['stats']['total_minutes'], + 'total_tracks': recap_data['stats']['total_tracks'], + 'top_track': recap_data['stats']['most_played_track'], + 'top_artist': recap_data['stats']['most_played_artist'], + 'personality_type': recap_data['personality']['personality_type'], + 'created_at': recap_data['created_at'] + } + + except Exception as e: + logger.error(f"Error getting recap summary: {e}") + return None + + async def _calculate_listening_stats(self, user_id: int, start_date: datetime.datetime, end_date: datetime.datetime) -> ListeningStats: + """Calculate comprehensive listening statistics""" + try: + with Session(db.engine) as session: + # Get all plays for the period + plays_query = select(Playlog).where( + and_( + Playlog.user_id == user_id, + Playlog.played_at >= start_date, + Playlog.played_at <= end_date + ) + ).order_by(Playlog.played_at) + + plays = session.execute(plays_query).scalars().all() + + if not plays: + return ListeningStats( + total_minutes=0, total_tracks=0, total_artists=0, total_albums=0, + unique_tracks=0, average_daily_minutes=0.0, most_played_track=None, + most_played_artist=None, most_played_album=None, top_genres=[], + listening_streak=0, longest_session=0, favorite_time_of_day="", + discovery_rate=0.0, repeat_listen_rate=0.0 + ) + + # Basic statistics + total_minutes = sum(play.duration or 0 for play in plays) + unique_tracks = len(set(play.track_id for play in plays)) + total_tracks = len(plays) + + # Get track details for artist/album counts + track_ids = list(set(play.track_id for play in plays)) + tracks_query = select(Track).where(Track.id.in_(track_ids)) + tracks = session.execute(tracks_query).scalars().all() + + unique_artists = len(set(track.artist for track in tracks)) + unique_albums = len(set(track.album for track in tracks)) + + # Most played items + track_counts = {} + artist_counts = {} + album_counts = {} + + for play in plays: + track = next((t for t in tracks if t.id == play.track_id), None) + if track: + # Track counts + track_counts[track.id] = track_counts.get(track.id, 0) + 1 + + # Artist counts + artist_counts[track.artist] = artist_counts.get(track.artist, 0) + 1 + + # Album counts + album_counts[track.album] = album_counts.get(track.album, 0) + 1 + + most_played_track_id = max(track_counts, key=track_counts.get) if track_counts else None + most_played_track = None + if most_played_track_id: + track = next((t for t in tracks if t.id == most_played_track_id), None) + if track: + most_played_track = { + 'id': track.id, + 'title': track.title, + 'artist': track.artist, + 'album': track.album, + 'play_count': track_counts[most_played_track_id] + } + + most_played_artist_name = max(artist_counts, key=artist_counts.get) if artist_counts else None + most_played_artist = { + 'name': most_played_artist_name, + 'play_count': artist_counts.get(most_played_artist_name, 0) + } if most_played_artist_name else None + + most_played_album_name = max(album_counts, key=album_counts.get) if album_counts else None + most_played_album = { + 'name': most_played_album_name, + 'play_count': album_counts.get(most_played_album_name, 0) + } if most_played_album_name else None + + # Calculate additional stats + days_in_period = (end_date - start_date).days + 1 + average_daily_minutes = total_minutes / days_in_period + + # Listening streak (consecutive days with plays) + listening_streak = await self._calculate_listening_streak(plays) + + # Longest session + longest_session = await self._calculate_longest_session(plays) + + # Favorite time of day + favorite_time_of_day = await self._calculate_favorite_time_of_day(plays) + + # Discovery and repeat rates + discovery_rate = await self._calculate_discovery_rate(user_id, plays) + repeat_listen_rate = (total_tracks - unique_tracks) / total_tracks if total_tracks > 0 else 0 + + return ListeningStats( + total_minutes=int(total_minutes), + total_tracks=total_tracks, + total_artists=unique_artists, + total_albums=unique_albums, + unique_tracks=unique_tracks, + average_daily_minutes=average_daily_minutes, + most_played_track=most_played_track, + most_played_artist=most_played_artist, + most_played_album=most_played_album, + top_genres=[], # Would need genre data from tracks + listening_streak=listening_streak, + longest_session=longest_session, + favorite_time_of_day=favorite_time_of_day, + discovery_rate=discovery_rate, + repeat_listen_rate=repeat_listen_rate + ) + + except Exception as e: + logger.error(f"Error calculating listening stats: {e}") + raise + + async def _analyze_music_personality(self, user_id: int, start_date: datetime.datetime, end_date: datetime.datetime) -> MusicPersonality: + """Analyze user's music personality based on listening patterns""" + try: + # This is a simplified version - would integrate with audio analyzer for deeper insights + with Session(db.engine) as session: + plays_query = select(Playlog).where( + and_( + Playlog.user_id == user_id, + Playlog.played_at >= start_date, + Playlog.played_at <= end_date + ) + ) + plays = session.execute(plays_query).scalars().all() + + if not plays: + return MusicPersonality( + personality_type="Explorer", + traits=["Curious", "Open-minded"], + description="You love discovering new music", + diversity_score=0.8, + exploration_score=0.9, + loyalty_score=0.3, + mood_profile={"energetic": 0.6, "relaxed": 0.4}, + genre_preferences={}, + audio_preferences={} + ) + + # Analyze patterns + track_ids = list(set(play.track_id for play in plays)) + tracks_query = select(Track).where(Track.id.in_(track_ids)) + tracks = session.execute(tracks_query).scalars().all() + + # Calculate metrics + unique_tracks = len(track_ids) + total_plays = len(plays) + diversity_score = unique_tracks / total_plays if total_plays > 0 else 0 + + # Determine personality type based on patterns + if diversity_score > 0.7: + personality_type = "Explorer" + traits = ["Curious", "Open-minded", "Adventurous"] + description = "You love discovering new music and exploring different genres" + elif diversity_score > 0.4: + personality_type = "Balanced" + traits = ["Versatile", "Open-minded", "Selective"] + description = "You enjoy both new discoveries and familiar favorites" + else: + personality_type = "Loyalist" + traits = ["Dedicated", "Selective", "Consistent"] + description = "You prefer to stick with what you love and dive deep into favorites" + + return MusicPersonality( + personality_type=personality_type, + traits=traits, + description=description, + diversity_score=diversity_score, + exploration_score=diversity_score, # Simplified + loyalty_score=1.0 - diversity_score, # Simplified + mood_profile={"energetic": 0.6, "relaxed": 0.4}, # Would analyze audio features + genre_preferences={}, # Would analyze genre data + audio_preferences={} # Would analyze audio features + ) + + except Exception as e: + logger.error(f"Error analyzing music personality: {e}") + raise + + async def _get_monthly_breakdown(self, user_id: int, year: int) -> List[Dict]: + """Get monthly listening breakdown""" + try: + monthly_data = [] + + for month in range(1, 13): + start_date = datetime.datetime(year, month, 1) + if month == 12: + end_date = datetime.datetime(year, 12, 31, 23, 59, 59) + else: + end_date = datetime.datetime(year, month + 1, 1) - datetime.timedelta(seconds=1) + + with Session(db.engine) as session: + plays_query = select(func.sum(Playlog.duration)).where( + and_( + Playlog.user_id == user_id, + Playlog.played_at >= start_date, + Playlog.played_at <= end_date + ) + ) + total_minutes = session.execute(plays_query).scalar() or 0 + + # Get track count + count_query = select(func.count(Playlog.id)).where( + and_( + Playlog.user_id == user_id, + Playlog.played_at >= start_date, + Playlog.played_at <= end_date + ) + ) + track_count = session.execute(count_query).scalar() or 0 + + monthly_data.append({ + 'month': month, + 'month_name': datetime.date(year, month, 1).strftime('%B'), + 'total_minutes': int(total_minutes), + 'track_count': track_count + }) + + return monthly_data + + except Exception as e: + logger.error(f"Error getting monthly breakdown: {e}") + return [] + + async def _get_top_tracks(self, user_id: int, start_date: datetime.datetime, end_date: datetime.datetime, limit: int) -> List[Dict]: + """Get top tracks for the period""" + try: + with Session(db.engine) as session: + # Get play counts + play_counts_query = select( + Playlog.track_id, + func.count(Playlog.id).label('play_count'), + func.sum(Playlog.duration).label('total_duration') + ).where( + and_( + Playlog.user_id == user_id, + Playlog.played_at >= start_date, + Playlog.played_at <= end_date + ) + ).group_by(Playlog.track_id).order_by(func.count(Playlog.id).desc()).limit(limit) + + play_counts = session.execute(play_counts_query).all() + + top_tracks = [] + for play_count in play_counts: + track = session.get(Track, play_count.track_id) + if track: + top_tracks.append({ + 'id': track.id, + 'title': track.title, + 'artist': track.artist, + 'album': track.album, + 'play_count': play_count.play_count, + 'total_duration': int(play_count.total_duration or 0), + 'image': track.image + }) + + return top_tracks + + except Exception as e: + logger.error(f"Error getting top tracks: {e}") + return [] + + async def _get_top_artists(self, user_id: int, start_date: datetime.datetime, end_date: datetime.datetime, limit: int) -> List[Dict]: + """Get top artists for the period""" + try: + with Session(db.engine) as session: + # Get artist play counts + artist_counts_query = select( + Track.artist, + func.count(Playlog.id).label('play_count'), + func.sum(Playlog.duration).label('total_duration'), + func.count(func.distinct(Track.id)).label('unique_tracks') + ).join(Playlog, Track.id == Playlog.track_id).where( + and_( + Playlog.user_id == user_id, + Playlog.played_at >= start_date, + Playlog.played_at <= end_date + ) + ).group_by(Track.artist).order_by(func.count(Playlog.id).desc()).limit(limit) + + artist_counts = session.execute(artist_counts_query).all() + + top_artists = [] + for artist_count in artist_counts: + top_artists.append({ + 'name': artist_count.artist, + 'play_count': artist_count.play_count, + 'total_duration': int(artist_count.total_duration or 0), + 'unique_tracks': artist_count.unique_tracks + }) + + return top_artists + + except Exception as e: + logger.error(f"Error getting top artists: {e}") + return [] + + async def _get_top_albums(self, user_id: int, start_date: datetime.datetime, end_date: datetime.datetime, limit: int) -> List[Dict]: + """Get top albums for the period""" + try: + with Session(db.engine) as session: + # Get album play counts + album_counts_query = select( + Track.album, + Track.artist, + func.count(Playlog.id).label('play_count'), + func.sum(Playlog.duration).label('total_duration'), + func.count(func.distinct(Track.id)).label('unique_tracks') + ).join(Playlog, Track.id == Playlog.track_id).where( + and_( + Playlog.user_id == user_id, + Playlog.played_at >= start_date, + Playlog.played_at <= end_date + ) + ).group_by(Track.album, Track.artist).order_by(func.count(Playlog.id).desc()).limit(limit) + + album_counts = session.execute(album_counts_query).all() + + top_albums = [] + for album_count in album_counts: + top_albums.append({ + 'name': album_count.album, + 'artist': album_count.artist, + 'play_count': album_count.play_count, + 'total_duration': int(album_count.total_duration or 0), + 'unique_tracks': album_count.unique_tracks + }) + + return top_albums + + except Exception as e: + logger.error(f"Error getting top albums: {e}") + return [] + + async def _get_new_discoveries(self, user_id: int, start_date: datetime.datetime, end_date: datetime.datetime) -> List[Dict]: + """Get tracks discovered during the period""" + try: + with Session(db.engine) as session: + # Get first play of each track in the period + first_plays_query = select( + Track.id, + Track.title, + Track.artist, + Track.album, + func.min(Playlog.played_at).label('first_played'), + func.count(Playlog.id).label('play_count') + ).join(Playlog, Track.id == Playlog.track_id).where( + and_( + Playlog.user_id == user_id, + Playlog.played_at >= start_date, + Playlog.played_at <= end_date + ) + ).group_by(Track.id, Track.title, Track.artist, Track.album).order_by(func.min(Playlog.played_at).desc()) + + discoveries = session.execute(first_plays_query).all() + + discovery_list = [] + for discovery in discoveries: + # Check if this was actually discovered in this period (no plays before start_date) + prior_plays_query = select(func.count(Playlog.id)).where( + and_( + Playlog.user_id == user_id, + Playlog.track_id == discovery.id, + Playlog.played_at < start_date + ) + ) + prior_plays = session.execute(prior_plays_query).scalar() or 0 + + if prior_plays == 0: # Truly discovered in this period + discovery_list.append({ + 'id': discovery.id, + 'title': discovery.title, + 'artist': discovery.artist, + 'album': discovery.album, + 'discovered_date': discovery.first_played.isoformat(), + 'play_count': discovery.play_count + }) + + return discovery_list[:50] # Limit to top 50 discoveries + + except Exception as e: + logger.error(f"Error getting new discoveries: {e}") + return [] + + async def _calculate_milestones(self, stats: ListeningStats, personality: MusicPersonality) -> List[Dict]: + """Calculate user milestones""" + milestones = [] + + # Listening time milestones + if stats.total_minutes >= 50000: # ~833 hours + milestones.append({ + 'type': 'listening_time', + 'title': 'Marathon Listener', + 'description': f'Listened for {stats.total_minutes // 60} hours this year!', + 'icon': 'clock', + 'level': 'gold' + }) + elif stats.total_minutes >= 25000: # ~417 hours + milestones.append({ + 'type': 'listening_time', + 'title': 'Dedicated Listener', + 'description': f'Listened for {stats.total_minutes // 60} hours this year!', + 'icon': 'clock', + 'level': 'silver' + }) + elif stats.total_minutes >= 10000: # ~167 hours + milestones.append({ + 'type': 'listening_time', + 'title': 'Music Enthusiast', + 'description': f'Listened for {stats.total_minutes // 60} hours this year!', + 'icon': 'clock', + 'level': 'bronze' + }) + + # Discovery milestones + if stats.unique_tracks >= 10000: + milestones.append({ + 'type': 'discovery', + 'title': 'Ultimate Explorer', + 'description': f'Discovered {stats.unique_tracks} unique tracks!', + 'icon': 'compass', + 'level': 'gold' + }) + elif stats.unique_tracks >= 5000: + milestones.append({ + 'type': 'discovery', + 'title': 'Music Explorer', + 'description': f'Discovered {stats.unique_tracks} unique tracks!', + 'icon': 'compass', + 'level': 'silver' + }) + elif stats.unique_tracks >= 1000: + milestones.append({ + 'type': 'discovery', + 'title': 'Curious Listener', + 'description': f'Discovered {stats.unique_tracks} unique tracks!', + 'icon': 'compass', + 'level': 'bronze' + }) + + # Streak milestones + if stats.listening_streak >= 365: + milestones.append({ + 'type': 'streak', + 'title': 'Everyday Listener', + 'description': f'Listened music every day for {stats.listening_streak} days!', + 'icon': 'calendar', + 'level': 'gold' + }) + elif stats.listening_streak >= 100: + milestones.append({ + 'type': 'streak', + 'title': 'Consistent Listener', + 'description': f'Listened music for {stats.listening_streak} consecutive days!', + 'icon': 'calendar', + 'level': 'silver' + }) + elif stats.listening_streak >= 30: + milestones.append({ + 'type': 'streak', + 'title': 'Monthly Streak', + 'description': f'Listened music for {stats.listening_streak} consecutive days!', + 'icon': 'calendar', + 'level': 'bronze' + }) + + return milestones + + async def _save_recap_data(self, recap_data: RecapData): + """Save recap data to file""" + try: + recap_file = self.recap_dir / f"recap_{recap_data.user_id}_{recap_data.year}.json" + + # Convert to dict and save + recap_dict = asdict(recap_data) + + with open(recap_file, 'w') as f: + json.dump(recap_dict, f, indent=2, default=str) + + logger.info(f"Saved recap data to {recap_file}") + + except Exception as e: + logger.error(f"Error saving recap data: {e}") + raise + + async def _calculate_listening_streak(self, plays: List) -> int: + """Calculate longest consecutive day streak""" + if not plays: + return 0 + + # Get unique days with plays + play_days = set(play.played_at.date() for play in plays) + sorted_days = sorted(play_days) + + max_streak = 0 + current_streak = 0 + + for i, day in enumerate(sorted_days): + if i == 0: + current_streak = 1 + else: + prev_day = sorted_days[i-1] + if (day - prev_day).days == 1: + current_streak += 1 + else: + current_streak = 1 + + max_streak = max(max_streak, current_streak) + + return max_streak + + async def _calculate_longest_session(self, plays: List) -> int: + """Calculate longest listening session""" + if not plays: + return 0 + + longest_session = 0 + current_session = 0 + + # Sort plays by time + sorted_plays = sorted(plays, key=lambda p: p.played_at) + + for i, play in enumerate(sorted_plays): + current_session = play.duration or 0 + + # Check if next play is within 30 minutes (continuation of session) + if i < len(sorted_plays) - 1: + next_play = sorted_plays[i + 1] + time_diff = (next_play.played_at - play.played_at).total_seconds() / 60 + + if time_diff <= 30: # Within 30 minutes = same session + current_session += next_play.duration or 0 + else: + longest_session = max(longest_session, current_session) + current_session = 0 + else: + longest_session = max(longest_session, current_session) + + return int(longest_session) + + async def _calculate_favorite_time_of_day(self, plays: List) -> str: + """Calculate favorite time of day for listening""" + if not plays: + return "" + + # Count plays by hour + hour_counts = {} + for play in plays: + hour = play.played_at.hour + hour_counts[hour] = hour_counts.get(hour, 0) + 1 + + # Find most common hour + favorite_hour = max(hour_counts, key=hour_counts.get) + + # Convert to time period + if 6 <= favorite_hour < 12: + return "Morning" + elif 12 <= favorite_hour < 18: + return "Afternoon" + elif 18 <= favorite_hour < 22: + return "Evening" + else: + return "Night" + + async def _calculate_discovery_rate(self, user_id: int, plays: List) -> float: + """Calculate rate of new music discovery""" + if not plays: + return 0.0 + + # Get first play date for each track + track_first_plays = {} + for play in plays: + if play.track_id not in track_first_plays: + track_first_plays[play.track_id] = play.played_at + + # Count tracks first played during this period vs total + period_start = min(play.played_at for play in plays) + period_end = max(play.played_at for play in plays) + + # Check if tracks were first discovered in this period + new_discoveries = 0 + for track_id, first_play in track_first_plays.items(): + if period_start <= first_play <= period_end: + # Check if there were any plays before this period + # This is simplified - would need to query database for prior plays + new_discoveries += 1 + + return new_discoveries / len(track_first_plays) if track_first_plays else 0.0 + + +# Global service instance +recap_service = RecapService() diff --git a/src/swingmusic/services/robust_statistics.py b/src/swingmusic/services/robust_statistics.py new file mode 100644 index 00000000..ee8d7d97 --- /dev/null +++ b/src/swingmusic/services/robust_statistics.py @@ -0,0 +1,839 @@ +""" +Robust Statistics System for SwingMusic +Prevents data loss with backup, validation, and integrity checks +""" + +import os +import time +import json +import sqlite3 +import threading +from typing import Dict, List, Optional, Any, Tuple +from dataclasses import dataclass, asdict +from datetime import datetime, timedelta +from pathlib import Path +import hashlib +import shutil + +from swingmusic import logger +from swingmusic.db.sqlite.utils import get_db_connection + + +@dataclass +class ListeningStats: + """Listening statistics for a track""" + user_id: str + track_id: str + play_count: int + last_played: float + total_time: int # Total seconds listened + skip_count: int + favorite: bool + rating: Optional[int] # 1-5 stars + created_at: float + updated_at: float + + +@dataclass +class ArtistStats: + """Artist-level statistics""" + artist_id: str + artist_name: str + total_plays: int + total_time: int + unique_tracks: int + last_played: float + favorite_tracks: List[str] + + +@dataclass +class AlbumStats: + """Album-level statistics""" + album_id: str + album_name: str + artist_name: str + total_plays: int + total_time: int + unique_tracks: int + last_played: float + completion_rate: float # Percentage of album listened to + + +@dataclass +class BackupEntry: + """Backup entry metadata""" + backup_id: str + timestamp: float + backup_type: str # 'full', 'incremental', 'auto' + file_path: str + checksum: str + size: int + compressed: bool + + +class StatisticsValidator: + """Validates statistics data integrity""" + + @staticmethod + def validate_listening_data(data: Dict[str, Any]) -> Tuple[bool, List[str]]: + """Validate listening statistics data""" + errors = [] + + # Required fields + required_fields = ['user_id', 'track_id', 'play_count', 'last_played'] + for field in required_fields: + if field not in data: + errors.append(f"Missing required field: {field}") + + # Data type validation + if 'play_count' in data and not isinstance(data['play_count'], int): + errors.append("play_count must be an integer") + + if 'last_played' in data and not isinstance(data['last_played'], (int, float)): + errors.append("last_played must be a timestamp") + + if 'total_time' in data and not isinstance(data['total_time'], int): + errors.append("total_time must be an integer") + + # Value validation + if 'play_count' in data and data['play_count'] < 0: + errors.append("play_count cannot be negative") + + if 'total_time' in data and data['total_time'] < 0: + errors.append("total_time cannot be negative") + + if 'rating' in data and data['rating'] is not None: + if not isinstance(data['rating'], int) or not (1 <= data['rating'] <= 5): + errors.append("rating must be an integer between 1 and 5") + + return len(errors) == 0, errors + + @staticmethod + def validate_timestamp_consistency(stats: List[ListeningStats]) -> List[str]: + """Validate timestamp consistency across statistics""" + errors = [] + + current_time = time.time() + + for stat in stats: + # Check for future timestamps + if stat.last_played > current_time + 60: # Allow 1 minute buffer + errors.append(f"Future timestamp detected for track {stat.track_id}") + + # Check for very old timestamps (before 2000) + if stat.last_played < 946684800: # Jan 1, 2000 + errors.append(f"Suspicious old timestamp for track {stat.track_id}") + + # Check if updated_at >= last_played + if stat.updated_at < stat.last_played: + errors.append(f"updated_at before last_played for track {stat.track_id}") + + return errors + + @staticmethod + def calculate_checksum(data: Any) -> str: + """Calculate SHA-256 checksum of data""" + if isinstance(data, str): + data_bytes = data.encode('utf-8') + elif isinstance(data, dict): + data_bytes = json.dumps(data, sort_keys=True).encode('utf-8') + else: + data_bytes = str(data).encode('utf-8') + + return hashlib.sha256(data_bytes).hexdigest() + + +class StatisticsBackup: + """Manages statistics backups with compression and verification""" + + def __init__(self, backup_dir: str = None): + self.backup_dir = backup_dir or os.path.join( + Path.home(), '.swingmusic', 'backups', 'statistics' + ) + os.makedirs(self.backup_dir, exist_ok=True) + + # Backup configuration + self.max_backups = 10 # Maximum number of backups to keep + self.auto_backup_interval = 3600 # 1 hour in seconds + self.compress_backups = True + + def create_backup(self, backup_type: str = 'auto') -> BackupEntry: + """Create a statistics backup""" + timestamp = time.time() + backup_id = f"stats_{backup_type}_{int(timestamp)}" + backup_file = os.path.join(self.backup_dir, f"{backup_id}.json") + + try: + # Collect statistics data + stats_data = self._collect_statistics_data() + + # Create backup entry + backup_entry = BackupEntry( + backup_id=backup_id, + timestamp=timestamp, + backup_type=backup_type, + file_path=backup_file, + checksum="", + size=0, + compressed=self.compress_backups + ) + + # Write backup file + with open(backup_file, 'w', encoding='utf-8') as f: + json.dump(stats_data, f, indent=2, ensure_ascii=False) + + # Calculate checksum and size + backup_entry.checksum = StatisticsValidator.calculate_checksum(stats_data) + backup_entry.size = os.path.getsize(backup_file) + + # Compress if enabled + if self.compress_backups: + backup_file = self._compress_backup(backup_file) + backup_entry.file_path = backup_file + backup_entry.size = os.path.getsize(backup_file) + + logger.info(f"Created statistics backup: {backup_id}") + return backup_entry + + except Exception as e: + logger.error(f"Failed to create statistics backup: {e}") + if os.path.exists(backup_file): + os.remove(backup_file) + raise + + def _collect_statistics_data(self) -> Dict[str, Any]: + """Collect all statistics data from database""" + try: + with get_db_connection() as conn: + # Get listening statistics + cursor = conn.execute(""" + SELECT + user_id, + trackhash as track_id, + playcount as play_count, + lastplayed as last_played, + total_time, + skip_count, + favorite, + rating, + created_at, + updated_at + FROM listening_stats + """) + + listening_stats = [dict(row) for row in cursor.fetchall()] + + # Get artist statistics + cursor = conn.execute(""" + SELECT + artist_id, + artist_name, + total_plays, + total_time, + unique_tracks, + last_played, + favorite_tracks + FROM artist_stats + """) + + artist_stats = [dict(row) for row in cursor.fetchall()] + + # Get album statistics + cursor = conn.execute(""" + SELECT + album_id, + album_name, + artist_name, + total_plays, + total_time, + unique_tracks, + last_played, + completion_rate + FROM album_stats + """) + + album_stats = [dict(row) for row in cursor.fetchall()] + + return { + 'backup_timestamp': time.time(), + 'listening_stats': listening_stats, + 'artist_stats': artist_stats, + 'album_stats': album_stats, + 'version': '1.0' + } + + except Exception as e: + logger.error(f"Error collecting statistics data: {e}") + return {} + + def _compress_backup(self, file_path: str) -> str: + """Compress backup file using gzip""" + try: + import gzip + + compressed_path = file_path + '.gz' + + with open(file_path, 'rb') as f_in: + with gzip.open(compressed_path, 'wb') as f_out: + shutil.copyfileobj(f_in, f_out) + + # Remove uncompressed file + os.remove(file_path) + + return compressed_path + + except ImportError: + logger.warning("gzip not available, backup not compressed") + return file_path + except Exception as e: + logger.error(f"Error compressing backup: {e}") + return file_path + + def restore_backup(self, backup_id: str) -> bool: + """Restore statistics from backup""" + backup_file = None + + try: + # Find backup file + if backup_id.endswith('.gz'): + backup_file = os.path.join(self.backup_dir, backup_id) + else: + backup_file = os.path.join(self.backup_dir, f"{backup_id}.json") + if not os.path.exists(backup_file): + backup_file = os.path.join(self.backup_dir, f"{backup_id}.json.gz") + + if not os.path.exists(backup_file): + logger.error(f"Backup file not found: {backup_id}") + return False + + # Load backup data + stats_data = self._load_backup_file(backup_file) + + if not stats_data: + logger.error("Failed to load backup data") + return False + + # Restore data to database + success = self._restore_statistics_data(stats_data) + + if success: + logger.info(f"Successfully restored statistics from backup: {backup_id}") + else: + logger.error(f"Failed to restore statistics from backup: {backup_id}") + + return success + + except Exception as e: + logger.error(f"Error restoring backup {backup_id}: {e}") + return False + + def _load_backup_file(self, file_path: str) -> Optional[Dict[str, Any]]: + """Load backup file (compressed or uncompressed)""" + try: + if file_path.endswith('.gz'): + import gzip + with gzip.open(file_path, 'rt', encoding='utf-8') as f: + return json.load(f) + else: + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + logger.error(f"Error loading backup file {file_path}: {e}") + return None + + def _restore_statistics_data(self, stats_data: Dict[str, Any]) -> bool: + """Restore statistics data to database""" + try: + with get_db_connection() as conn: + # Clear existing statistics + conn.execute("DELETE FROM listening_stats") + conn.execute("DELETE FROM artist_stats") + conn.execute("DELETE FROM album_stats") + + # Restore listening statistics + if 'listening_stats' in stats_data: + for stat in stats_data['listening_stats']: + conn.execute(""" + INSERT INTO listening_stats ( + user_id, trackhash, playcount, lastplayed, total_time, + skip_count, favorite, rating, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + stat['user_id'], + stat['track_id'], + stat['play_count'], + stat['last_played'], + stat['total_time'], + stat.get('skip_count', 0), + stat.get('favorite', False), + stat.get('rating'), + stat.get('created_at', time.time()), + stat.get('updated_at', time.time()) + )) + + # Restore artist statistics + if 'artist_stats' in stats_data: + for stat in stats_data['artist_stats']: + conn.execute(""" + INSERT INTO artist_stats ( + artist_id, artist_name, total_plays, total_time, + unique_tracks, last_played, favorite_tracks + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + stat['artist_id'], + stat['artist_name'], + stat['total_plays'], + stat['total_time'], + stat['unique_tracks'], + stat['last_played'], + json.dumps(stat.get('favorite_tracks', [])) + )) + + # Restore album statistics + if 'album_stats' in stats_data: + for stat in stats_data['album_stats']: + conn.execute(""" + INSERT INTO album_stats ( + album_id, album_name, artist_name, total_plays, + total_time, unique_tracks, last_played, completion_rate + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + stat['album_id'], + stat['album_name'], + stat['artist_name'], + stat['total_plays'], + stat['total_time'], + stat['unique_tracks'], + stat['last_played'], + stat.get('completion_rate', 0.0) + )) + + conn.commit() + return True + + except Exception as e: + logger.error(f"Error restoring statistics data: {e}") + return False + + def list_backups(self) -> List[BackupEntry]: + """List all available backups""" + backups = [] + + try: + for file_name in os.listdir(self.backup_dir): + if file_name.endswith(('.json', '.gz')): + file_path = os.path.join(self.backup_dir, file_name) + + # Extract backup info from filename + parts = file_name.replace('.json', '').replace('.gz', '').split('_') + if len(parts) >= 3: + backup_type = parts[1] + timestamp = float(parts[2]) + + backup_entry = BackupEntry( + backup_id=file_name.replace('.json', '').replace('.gz', ''), + timestamp=timestamp, + backup_type=backup_type, + file_path=file_path, + checksum="", + size=os.path.getsize(file_path), + compressed=file_path.endswith('.gz') + ) + + backups.append(backup_entry) + + # Sort by timestamp (newest first) + backups.sort(key=lambda x: x.timestamp, reverse=True) + + except Exception as e: + logger.error(f"Error listing backups: {e}") + + return backups + + def cleanup_old_backups(self): + """Remove old backups, keeping only the most recent ones""" + backups = self.list_backups() + + if len(backups) > self.max_backups: + # Keep the most recent backups + backups_to_keep = backups[:self.max_backups] + backups_to_remove = backups[self.max_backups:] + + for backup in backups_to_remove: + try: + os.remove(backup.file_path) + logger.info(f"Removed old backup: {backup.backup_id}") + except Exception as e: + logger.error(f"Error removing backup {backup.backup_id}: {e}") + + +class RobustStatisticsManager: + """Robust statistics manager with backup and validation""" + + def __init__(self): + self.backup_manager = StatisticsBackup() + self.validator = StatisticsValidator() + self.last_backup_time = 0 + self.backup_lock = threading.Lock() + + # Start auto-backup thread + self._start_auto_backup() + + def _start_auto_backup(self): + """Start automatic backup thread""" + def backup_worker(): + while True: + time.sleep(self.backup_manager.auto_backup_interval) + try: + self._create_auto_backup() + except Exception as e: + logger.error(f"Auto-backup failed: {e}") + + backup_thread = threading.Thread(target=backup_worker, daemon=True) + backup_thread.start() + + def _create_auto_backup(self): + """Create automatic backup""" + with self.backup_lock: + try: + self.backup_manager.create_backup('auto') + self.last_backup_time = time.time() + self.backup_manager.cleanup_old_backups() + except Exception as e: + logger.error(f"Auto-backup failed: {e}") + + async def update_listening_stats(self, user_id: str, track_id: str, + listening_data: Dict[str, Any]) -> bool: + """Update statistics with data integrity checks""" + try: + # Validate data before storage + is_valid, errors = self.validator.validate_listening_data(listening_data) + if not is_valid: + logger.error(f"Invalid listening data: {errors}") + return False + + # Create backup before update + backup_success = self._create_update_backup(user_id) + if not backup_success: + logger.warning("Failed to create backup before statistics update") + + # Update with transaction + with get_db_connection() as conn: + conn.execute("BEGIN TRANSACTION") + + try: + # Update or insert listening stats + cursor = conn.execute(""" + SELECT playcount, total_time, skip_count, favorite, rating + FROM listening_stats + WHERE user_id = ? AND trackhash = ? + """, (user_id, track_id)) + + existing = cursor.fetchone() + + if existing: + # Update existing record + new_play_count = existing['playcount'] + listening_data.get('play_count', 1) + new_total_time = existing['total_time'] + listening_data.get('duration', 0) + new_skip_count = existing['skip_count'] + listening_data.get('skip_count', 0) + + conn.execute(""" + UPDATE listening_stats + SET playcount = ?, lastplayed = ?, total_time = ?, + skip_count = ?, updated_at = ? + WHERE user_id = ? AND trackhash = ? + """, ( + new_play_count, + listening_data.get('last_played', time.time()), + new_total_time, + new_skip_count, + time.time(), + user_id, + track_id + )) + else: + # Insert new record + conn.execute(""" + INSERT INTO listening_stats ( + user_id, trackhash, playcount, lastplayed, total_time, + skip_count, favorite, rating, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + user_id, + track_id, + listening_data.get('play_count', 1), + listening_data.get('last_played', time.time()), + listening_data.get('duration', 0), + listening_data.get('skip_count', 0), + listening_data.get('favorite', False), + listening_data.get('rating'), + time.time(), + time.time() + )) + + # Update artist and album statistics + await self._update_artist_stats(conn, user_id, track_id) + await self._update_album_stats(conn, user_id, track_id) + + conn.commit() + + # Verify integrity after update + await self._verify_integrity(user_id) + + return True + + except Exception as e: + conn.rollback() + logger.error(f"Error updating statistics: {e}") + + # Attempt to restore from backup + if backup_success: + self._restore_from_backup(user_id) + + return False + + except Exception as e: + logger.error(f"Error in update_listening_stats: {e}") + return False + + async def _update_artist_stats(self, conn: sqlite3.Connection, user_id: str, track_id: str): + """Update artist-level statistics""" + try: + # Get track information + cursor = conn.execute(""" + SELECT artist, album FROM tracks WHERE trackhash = ? + """, (track_id,)) + + track_info = cursor.fetchone() + if not track_info: + return + + artist = track_info['artist'] + + # Update artist statistics + cursor = conn.execute(""" + SELECT total_plays, total_time, unique_tracks, last_played + FROM artist_stats + WHERE artist_id = ? AND user_id = ? + """, (artist, user_id)) + + existing = cursor.fetchone() + + if existing: + # Update existing + cursor = conn.execute(""" + SELECT COUNT(DISTINCT trackhash) as unique_count + FROM listening_stats + WHERE user_id = ? AND trackhash IN ( + SELECT trackhash FROM tracks WHERE artist = ? + ) + """, (user_id, artist)) + + unique_tracks = cursor.fetchone()['unique_count'] + + conn.execute(""" + UPDATE artist_stats + SET total_plays = total_plays + 1, + total_time = total_time + ?, + unique_tracks = ?, + last_played = ? + WHERE artist_id = ? AND user_id = ? + """, ( + track_info.get('duration', 0), + unique_tracks, + time.time(), + artist, + user_id + )) + else: + # Insert new + conn.execute(""" + INSERT INTO artist_stats ( + artist_id, artist_name, user_id, total_plays, total_time, + unique_tracks, last_played, favorite_tracks + ) VALUES (?, ?, ?, 1, ?, 1, ?, ?) + """, ( + artist, + artist, + user_id, + track_info.get('duration', 0), + time.time(), + json.dumps([]) + )) + + except Exception as e: + logger.error(f"Error updating artist stats: {e}") + + async def _update_album_stats(self, conn: sqlite3.Connection, user_id: str, track_id: str): + """Update album-level statistics""" + try: + # Get track information + cursor = conn.execute(""" + SELECT artist, album FROM tracks WHERE trackhash = ? + """, (track_id,)) + + track_info = cursor.fetchone() + if not track_info: + return + + album = track_info['album'] + artist = track_info['artist'] + + # Update album statistics + cursor = conn.execute(""" + SELECT total_plays, total_time, unique_tracks, last_played + FROM album_stats + WHERE album_id = ? AND user_id = ? + """, (album, user_id)) + + existing = cursor.fetchone() + + if existing: + # Update existing + cursor = conn.execute(""" + SELECT COUNT(DISTINCT trackhash) as unique_count + FROM listening_stats + WHERE user_id = ? AND trackhash IN ( + SELECT trackhash FROM tracks WHERE album = ? + ) + """, (user_id, album)) + + unique_tracks = cursor.fetchone()['unique_count'] + + conn.execute(""" + UPDATE album_stats + SET total_plays = total_plays + 1, + total_time = total_time + ?, + unique_tracks = ?, + last_played = ? + WHERE album_id = ? AND user_id = ? + """, ( + track_info.get('duration', 0), + unique_tracks, + time.time(), + album, + user_id + )) + else: + # Insert new + conn.execute(""" + INSERT INTO album_stats ( + album_id, album_name, artist_name, user_id, total_plays, + total_time, unique_tracks, last_played, completion_rate + ) VALUES (?, ?, ?, ?, 1, ?, 1, ?, 0.0) + """, ( + album, + album, + artist, + user_id, + track_info.get('duration', 0), + time.time() + )) + + except Exception as e: + logger.error(f"Error updating album stats: {e}") + + async def _verify_integrity(self, user_id: str): + """Verify statistics integrity after update""" + try: + with get_db_connection() as conn: + # Get all listening stats for user + cursor = conn.execute(""" + SELECT * FROM listening_stats WHERE user_id = ? + """, (user_id,)) + + stats = [ListeningStats(**dict(row)) for row in cursor.fetchall()] + + # Validate timestamp consistency + errors = self.validator.validate_timestamp_consistency(stats) + + if errors: + logger.warning(f"Statistics integrity issues for user {user_id}: {errors}") + + except Exception as e: + logger.error(f"Error verifying statistics integrity: {e}") + + def _create_update_backup(self, user_id: str) -> bool: + """Create backup before statistics update""" + try: + with self.backup_lock: + backup_id = f"pre_update_{user_id}_{int(time.time())}" + backup_entry = self.backup_manager.create_backup('update') + return True + except Exception as e: + logger.error(f"Failed to create update backup: {e}") + return False + + def _restore_from_backup(self, user_id: str): + """Restore statistics from most recent backup""" + try: + backups = self.backup_manager.list_backups() + if backups: + # Find the most recent backup + latest_backup = backups[0] + success = self.backup_manager.restore_backup(latest_backup.backup_id) + + if success: + logger.info(f"Restored statistics from backup: {latest_backup.backup_id}") + else: + logger.error(f"Failed to restore from backup: {latest_backup.backup_id}") + + except Exception as e: + logger.error(f"Error restoring from backup: {e}") + + def get_statistics_summary(self, user_id: str) -> Dict[str, Any]: + """Get statistics summary for user""" + try: + with get_db_connection() as conn: + # Get overall statistics + cursor = conn.execute(""" + SELECT + COUNT(*) as total_tracks, + SUM(playcount) as total_plays, + SUM(total_time) as total_time, + COUNT(DISTINCT artist) as unique_artists, + COUNT(DISTINCT album) as unique_albums + FROM listening_stats ls + JOIN tracks t ON ls.trackhash = t.trackhash + WHERE ls.user_id = ? + """, (user_id,)) + + overall = cursor.fetchone() + + # Get top tracks + cursor = conn.execute(""" + SELECT t.title, t.artist, ls.playcount, ls.lastplayed + FROM listening_stats ls + JOIN tracks t ON ls.trackhash = t.trackhash + WHERE ls.user_id = ? + ORDER BY ls.playcount DESC + LIMIT 10 + """, (user_id,)) + + top_tracks = [dict(row) for row in cursor.fetchall()] + + # Get top artists + cursor = conn.execute(""" + SELECT artist_name, total_plays, total_time + FROM artist_stats + WHERE user_id = ? + ORDER BY total_plays DESC + LIMIT 10 + """, (user_id,)) + + top_artists = [dict(row) for row in cursor.fetchall()] + + return { + 'overall': dict(overall) if overall else {}, + 'top_tracks': top_tracks, + 'top_artists': top_artists, + 'last_backup': self.last_backup_time + } + + except Exception as e: + logger.error(f"Error getting statistics summary: {e}") + return {} + + +# Global robust statistics manager instance +robust_statistics_manager = RobustStatisticsManager() diff --git a/src/swingmusic/services/spotify_metadata_client.py b/src/swingmusic/services/spotify_metadata_client.py new file mode 100644 index 00000000..a5304bcb --- /dev/null +++ b/src/swingmusic/services/spotify_metadata_client.py @@ -0,0 +1,577 @@ +""" +Spotify Metadata Client for SwingMusic +Handles fetching metadata from Spotify API for catalog browsing and downloads +""" + +import os +import json +import time +import base64 +import requests +from typing import Dict, List, Optional, Any, Tuple +from dataclasses import dataclass +from urllib.parse import urlencode + +from swingmusic.logger import log as logger + + +@dataclass +class SpotifyTrack: + """Spotify track metadata""" + id: str + name: str + artists: List[Dict[str, Any]] + album: Dict[str, Any] + duration_ms: int + popularity: int + preview_url: Optional[str] + explicit: bool + external_urls: Dict[str, str] + track_number: int + disc_number: int + available_markets: List[str] + + +@dataclass +class SpotifyAlbum: + """Spotify album metadata""" + id: str + name: str + artists: List[Dict[str, Any]] + release_date: str + total_tracks: int + popularity: int + images: List[Dict[str, str]] + external_urls: Dict[str, str] + available_markets: List[str] + album_type: str # album, single, compilation + + +@dataclass +class SpotifyArtist: + """Spotify artist metadata""" + id: str + name: str + popularity: int + followers: Dict[str, int] + genres: List[str] + images: List[Dict[str, str]] + external_urls: Dict[str, str] + + +@dataclass +class SpotifyPlaylist: + """Spotify playlist metadata""" + id: str + name: str + description: Optional[str] + owner: Dict[str, Any] + public: bool + collaborative: bool + tracks: Dict[str, Any] # Contains href, total, limit + images: List[Dict[str, str]] + external_urls: Dict[str, str] + + +class SpotifyMetadataClient: + """Client for accessing Spotify Web API for metadata""" + + def __init__(self): + self.client_id = os.getenv('SPOTIFY_CLIENT_ID', '') + self.client_secret = os.getenv('SPOTIFY_CLIENT_SECRET', '') + self.access_token = None + self.token_expires_at = 0 + self.base_url = 'https://api.spotify.com/v1' + self.rate_limit_remaining = 0 + self.rate_limit_reset = 0 + + # Fallback to demo/public endpoints for development + self.use_demo_mode = not (self.client_id and self.client_secret) + + if self.use_demo_mode: + logger.warning("Spotify client credentials not configured, using demo mode") + + def _get_access_token(self) -> Optional[str]: + """Get or refresh Spotify access token""" + if self.use_demo_mode: + return "demo_token" + + # Check if current token is still valid + if self.access_token and time.time() < self.token_expires_at: + return self.access_token + + try: + # Request new token + auth_string = base64.b64encode( + f"{self.client_id}:{self.client_secret}".encode('utf-8') + ).decode('utf-8') + + response = requests.post( + 'https://accounts.spotify.com/api/token', + headers={ + 'Authorization': f'Basic {auth_string}', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + data='grant_type=client_credentials' + ) + + if response.status_code == 200: + data = response.json() + self.access_token = data['access_token'] + self.token_expires_at = time.time() + data['expires_in'] - 60 # 1 minute buffer + logger.info("Successfully obtained Spotify access token") + return self.access_token + else: + logger.error(f"Failed to get Spotify token: {response.status_code} {response.text}") + return None + + except Exception as e: + logger.error(f"Error getting Spotify access token: {e}") + return None + + def _make_request(self, endpoint: str, params: Dict[str, Any] = None) -> Optional[Dict[str, Any]]: + """Make authenticated request to Spotify API""" + if self.use_demo_mode: + return self._demo_response(endpoint, params) + + token = self._get_access_token() + if not token: + return None + + # Check rate limiting + if self.rate_limit_remaining <= 0 and time.time() < self.rate_limit_reset: + wait_time = self.rate_limit_reset - time.time() + logger.warning(f"Rate limited, waiting {wait_time:.2f} seconds") + time.sleep(wait_time) + + try: + url = f"{self.base_url}/{endpoint.lstrip('/')}" + if params: + url += f"?{urlencode(params)}" + + response = requests.get( + url, + headers={ + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + ) + + # Update rate limit info + self.rate_limit_remaining = int(response.headers.get('X-RateLimit-Remaining', 0)) + self.rate_limit_reset = int(response.headers.get('X-RateLimit-Reset', 0)) + + if response.status_code == 200: + return response.json() + elif response.status_code == 429: + retry_after = int(response.headers.get('Retry-After', 5)) + logger.warning(f"Rate limited, retrying after {retry_after} seconds") + time.sleep(retry_after) + return self._make_request(endpoint, params) + elif response.status_code == 401: + # Token expired, refresh and retry + self.access_token = None + return self._make_request(endpoint, params) + else: + logger.error(f"Spotify API error: {response.status_code} {response.text}") + return None + + except Exception as e: + logger.error(f"Error making Spotify API request: {e}") + return None + + def _demo_response(self, endpoint: str, params: Dict[str, Any] = None) -> Optional[Dict[str, Any]]: + """Generate demo responses for development""" + logger.info(f"Demo mode response for: {endpoint}") + + if 'tracks' in endpoint: + track_id = endpoint.split('/')[-1] if '/' in endpoint else 'demo_track' + return { + 'id': track_id, + 'name': f'Demo Track {track_id}', + 'artists': [{'id': 'demo_artist', 'name': 'Demo Artist'}], + 'album': { + 'id': 'demo_album', + 'name': 'Demo Album', + 'images': [{'url': 'https://via.placeholder.com/300'}] + }, + 'duration_ms': 180000, + 'popularity': 75, + 'preview_url': None, + 'explicit': False, + 'external_urls': {'spotify': f'https://open.spotify.com/track/{track_id}'}, + 'track_number': 1, + 'disc_number': 1, + 'available_markets': ['US', 'GB', 'DE'] + } + elif 'albums' in endpoint: + album_id = endpoint.split('/')[-1] if '/' in endpoint else 'demo_album' + return { + 'id': album_id, + 'name': f'Demo Album {album_id}', + 'artists': [{'id': 'demo_artist', 'name': 'Demo Artist'}], + 'release_date': '2024-01-01', + 'total_tracks': 10, + 'popularity': 70, + 'images': [{'url': 'https://via.placeholder.com/300'}], + 'external_urls': {'spotify': f'https://open.spotify.com/album/{album_id}'}, + 'available_markets': ['US', 'GB', 'DE'], + 'album_type': 'album', + 'tracks': { + 'items': [ + { + 'id': f'demo_track_{i}', + 'name': f'Demo Track {i+1}', + 'duration_ms': 180000, + 'track_number': i+1, + 'explicit': False + } + for i in range(10) + ] + } + } + elif 'artists' in endpoint: + if 'albums' in endpoint: + return { + 'items': [ + { + 'id': f'demo_album_{i}', + 'name': f'Demo Album {i+1}', + 'release_date': '2024-01-01', + 'total_tracks': 10, + 'images': [{'url': 'https://via.placeholder.com/300'}], + 'album_type': 'album' + } + for i in range(5) + ] + } + elif 'top-tracks' in endpoint: + return { + 'tracks': [ + { + 'id': f'demo_track_{i}', + 'name': f'Demo Track {i+1}', + 'artists': [{'id': 'demo_artist', 'name': 'Demo Artist'}], + 'album': { + 'id': 'demo_album', + 'name': 'Demo Album', + 'images': [{'url': 'https://via.placeholder.com/300'}] + }, + 'duration_ms': 180000, + 'popularity': 80 - i, + 'preview_url': None, + 'explicit': False, + 'external_urls': {'spotify': f'https://open.spotify.com/track/demo_track_{i}'}, + 'track_number': i+1 + } + for i in range(15) + ] + } + else: + artist_id = endpoint.split('/')[-1] if '/' in endpoint else 'demo_artist' + return { + 'id': artist_id, + 'name': f'Demo Artist {artist_id}', + 'popularity': 75, + 'followers': {'total': 1000000}, + 'genres': ['Demo Genre', 'Test Genre'], + 'images': [{'url': 'https://via.placeholder.com/300'}], + 'external_urls': {'spotify': f'https://open.spotify.com/artist/{artist_id}'} + } + elif 'search' in endpoint: + query = params.get('q', '') if params else '' + return { + 'tracks': { + 'items': [ + { + 'id': f'search_track_{i}', + 'name': f'{query} Track {i+1}', + 'artists': [{'id': 'search_artist', 'name': f'{query} Artist'}], + 'album': { + 'id': 'search_album', + 'name': f'{query} Album', + 'images': [{'url': 'https://via.placeholder.com/300'}] + }, + 'duration_ms': 180000, + 'popularity': 70 - i, + 'explicit': False + } + for i in range(min(params.get('limit', 20) if params else 20, 20)) + ], + 'total': 100 + }, + 'albums': { + 'items': [ + { + 'id': f'search_album_{i}', + 'name': f'{query} Album {i+1}', + 'artists': [{'id': 'search_artist', 'name': f'{query} Artist'}], + 'release_date': '2024-01-01', + 'total_tracks': 10, + 'images': [{'url': 'https://via.placeholder.com/300'}], + 'album_type': 'album' + } + for i in range(min(params.get('limit', 20) if params else 20, 20)) + ], + 'total': 50 + }, + 'artists': { + 'items': [ + { + 'id': f'search_artist_{i}', + 'name': f'{query} Artist {i+1}', + 'popularity': 70 - i, + 'followers': {'total': 100000 * (i+1)}, + 'genres': ['Search Genre'], + 'images': [{'url': 'https://via.placeholder.com/300'}] + } + for i in range(min(params.get('limit', 20) if params else 20, 20)) + ], + 'total': 25 + } + } + + return None + + def get_track(self, track_id: str) -> Optional[SpotifyTrack]: + """Get track by ID""" + data = self._make_request(f'tracks/{track_id}') + if not data: + return None + + return SpotifyTrack( + id=data['id'], + name=data['name'], + artists=data['artists'], + album=data['album'], + duration_ms=data['duration_ms'], + popularity=data['popularity'], + preview_url=data.get('preview_url'), + explicit=data['explicit'], + external_urls=data['external_urls'], + track_number=data['track_number'], + disc_number=data.get('disc_number', 1), + available_markets=data.get('available_markets', []) + ) + + def get_album(self, album_id: str) -> Optional[SpotifyAlbum]: + """Get album by ID""" + data = self._make_request(f'albums/{album_id}') + if not data: + return None + + return SpotifyAlbum( + id=data['id'], + name=data['name'], + artists=data['artists'], + release_date=data['release_date'], + total_tracks=data['total_tracks'], + popularity=data.get('popularity', 0), + images=data['images'], + external_urls=data['external_urls'], + available_markets=data.get('available_markets', []), + album_type=data['album_type'] + ) + + def get_album_tracks(self, album_id: str, limit: int = 50, offset: int = 0) -> List[SpotifyTrack]: + """Get tracks from album""" + data = self._make_request(f'albums/{album_id}/tracks', { + 'limit': limit, + 'offset': offset + }) + + if not data or 'items' not in data: + return [] + + tracks = [] + for item in data['items']: + # Get full track details for each track + track = self.get_track(item['id']) + if track: + tracks.append(track) + + return tracks + + def get_artist(self, artist_id: str) -> Optional[SpotifyArtist]: + """Get artist by ID""" + data = self._make_request(f'artists/{artist_id}') + if not data: + return None + + return SpotifyArtist( + id=data['id'], + name=data['name'], + popularity=data['popularity'], + followers=data['followers'], + genres=data['genres'], + images=data['images'], + external_urls=data['external_urls'] + ) + + def get_artist_albums(self, artist_id: str, limit: int = 20, include_groups: str = 'album,single') -> List[SpotifyAlbum]: + """Get artist albums""" + data = self._make_request(f'artists/{artist_id}/albums', { + 'limit': limit, + 'include_groups': include_groups + }) + + if not data or 'items' not in data: + return [] + + albums = [] + for item in data['items']: + album = SpotifyAlbum( + id=item['id'], + name=item['name'], + artists=item['artists'], + release_date=item['release_date'], + total_tracks=item['total_tracks'], + popularity=item.get('popularity', 0), + images=item['images'], + external_urls=item['external_urls'], + available_markets=item.get('available_markets', []), + album_type=item['album_type'] + ) + albums.append(album) + + return albums + + def get_artist_top_tracks(self, artist_id: str, market: str = 'US') -> List[SpotifyTrack]: + """Get artist's top tracks""" + data = self._make_request(f'artists/{artist_id}/top-tracks', { + 'market': market + }) + + if not data or 'tracks' not in data: + return [] + + tracks = [] + for item in data['tracks']: + track = SpotifyTrack( + id=item['id'], + name=item['name'], + artists=item['artists'], + album=item['album'], + duration_ms=item['duration_ms'], + popularity=item['popularity'], + preview_url=item.get('preview_url'), + explicit=item['explicit'], + external_urls=item['external_urls'], + track_number=item.get('track_number', 1), + disc_number=item.get('disc_number', 1), + available_markets=item.get('available_markets', []) + ) + tracks.append(track) + + return tracks + + def get_related_artists(self, artist_id: str) -> List[SpotifyArtist]: + """Get related artists""" + data = self._make_request(f'artists/{artist_id}/related-artists') + + if not data or 'artists' not in data: + return [] + + artists = [] + for item in data['artists']: + artist = SpotifyArtist( + id=item['id'], + name=item['name'], + popularity=item['popularity'], + followers=item['followers'], + genres=item['genres'], + images=item['images'], + external_urls=item['external_urls'] + ) + artists.append(artist) + + return artists + + def search(self, query: str, search_type: str = 'track', limit: int = 20, offset: int = 0, market: str = 'US') -> Dict[str, List]: + """Search for content""" + types = search_type if search_type in ['track', 'album', 'artist', 'playlist'] else 'track' + + data = self._make_request('search', { + 'q': query, + 'type': types, + 'limit': limit, + 'offset': offset, + 'market': market + }) + + if not data: + return {'tracks': [], 'albums': [], 'artists': [], 'playlists': []} + + result = {'tracks': [], 'albums': [], 'artists': [], 'playlists': []} + + # Process tracks + if 'tracks' in data and 'items' in data['tracks']: + for item in data['tracks']['items']: + track = SpotifyTrack( + id=item['id'], + name=item['name'], + artists=item['artists'], + album=item['album'], + duration_ms=item['duration_ms'], + popularity=item['popularity'], + preview_url=item.get('preview_url'), + explicit=item['explicit'], + external_urls=item['external_urls'], + track_number=item.get('track_number', 1), + disc_number=item.get('disc_number', 1), + available_markets=item.get('available_markets', []) + ) + result['tracks'].append(track) + + # Process albums + if 'albums' in data and 'items' in data['albums']: + for item in data['albums']['items']: + album = SpotifyAlbum( + id=item['id'], + name=item['name'], + artists=item['artists'], + release_date=item['release_date'], + total_tracks=item['total_tracks'], + popularity=item.get('popularity', 0), + images=item['images'], + external_urls=item['external_urls'], + available_markets=item.get('available_markets', []), + album_type=item['album_type'] + ) + result['albums'].append(album) + + # Process artists + if 'artists' in data and 'items' in data['artists']: + for item in data['artists']['items']: + artist = SpotifyArtist( + id=item['id'], + name=item['name'], + popularity=item['popularity'], + followers=item['followers'], + genres=item['genres'], + images=item['images'], + external_urls=item['external_urls'] + ) + result['artists'].append(artist) + + # Process playlists + if 'playlists' in data and 'items' in data['playlists']: + for item in data['playlists']['items']: + playlist = SpotifyPlaylist( + id=item['id'], + name=item['name'], + description=item.get('description'), + owner=item['owner'], + public=item.get('public', False), + collaborative=item.get('collaborative', False), + tracks=item['tracks'], + images=item.get('images', []), + external_urls=item['external_urls'] + ) + result['playlists'].append(playlist) + + return result + + +# Global instance +spotify_metadata_client = SpotifyMetadataClient() diff --git a/src/swingmusic/services/universal_music_downloader.py b/src/swingmusic/services/universal_music_downloader.py new file mode 100644 index 00000000..ccbc680e --- /dev/null +++ b/src/swingmusic/services/universal_music_downloader.py @@ -0,0 +1,343 @@ +""" +Universal Music Downloader - Minimal Working Version +""" + +import os +import time +import asyncio +import aiohttp +from typing import Dict, List, Optional, Any +from dataclasses import dataclass +from enum import Enum + +from swingmusic.services.universal_url_parser import universal_url_parser, MusicService, ParsedURL +import logging + +logger = logging.getLogger(__name__) + + +class DownloadStatus(Enum): + PENDING = "pending" + DOWNLOADING = "downloading" + COMPLETED = "completed" + FAILED = "failed" + + +class DownloadQuality(Enum): + LOSSLESS = "lossless" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + +@dataclass +class UniversalMetadata: + """Universal metadata structure for all music services""" + service: MusicService + service_id: str + title: str + artist: str + album: Optional[str] = None + duration_ms: Optional[int] = None + isrc: Optional[str] = None + release_date: Optional[str] = None + genre: Optional[str] = None + image_url: Optional[str] = None + original_url: str = "" + metadata: Dict[str, Any] = None + + def __post_init__(self): + if self.metadata is None: + self.metadata = {} + + +@dataclass +class DownloadItem: + """Represents a download item in the queue""" + id: str + url: str + metadata: UniversalMetadata + quality: DownloadQuality + status: DownloadStatus + progress: float = 0.0 + file_path: Optional[str] = None + error_message: Optional[str] = None + created_at: float = None + + def __post_init__(self): + if self.created_at is None: + self.created_at = time.time() + + +class UniversalMusicDownloader: + """Universal music downloader supporting multiple streaming services""" + + def __init__(self, download_dir: str = None, max_concurrent_downloads: int = 3): + self.download_dir = download_dir or os.path.expanduser("~/Downloads/SwingMusic") + self.max_concurrent_downloads = max_concurrent_downloads + self.default_quality = DownloadQuality.HIGH + self.download_queue: List[DownloadItem] = [] + self.session = None + + # Ensure download directory exists + os.makedirs(self.download_dir, exist_ok=True) + + async def _get_session(self) -> aiohttp.ClientSession: + """Get or create aiohttp session""" + if self.session is None: + self.session = aiohttp.ClientSession() + return self.session + + async def close(self): + """Close aiohttp session""" + if self.session: + await self.session.close() + + def parse_url(self, url: str) -> Optional[ParsedURL]: + """Parse and validate a music service URL""" + return universal_url_parser.parse_url(url) + + async def get_metadata(self, url: str) -> Optional[UniversalMetadata]: + """Get metadata from any supported music service URL""" + try: + # Parse URL + parsed_url = universal_url_parser.parse_url(url) + if not parsed_url: + logger.warning(f"Could not parse URL: {url}") + return None + + # Route to appropriate service + if parsed_url.service == MusicService.SPOTIFY: + return await self._get_spotify_metadata(parsed_url) + elif parsed_url.service == MusicService.TIDAL: + return await self._get_tidal_metadata(parsed_url) + elif parsed_url.service == MusicService.APPLE_MUSIC: + return await self._get_apple_music_metadata(parsed_url) + elif parsed_url.service == MusicService.YOUTUBE: + return await self._get_youtube_metadata(parsed_url) + elif parsed_url.service == MusicService.YOUTUBE_MUSIC: + return await self._get_youtube_music_metadata(parsed_url) + elif parsed_url.service == MusicService.SOUNDCLOUD: + return await self._get_soundcloud_metadata(parsed_url) + elif parsed_url.service == MusicService.DEEZER: + return await self._get_deezer_metadata(parsed_url) + elif parsed_url.service == MusicService.MUSICBRAINZ: + return await self._get_musicbrainz_metadata(parsed_url) + elif parsed_url.service == MusicService.DISCOGS: + return await self._get_discogs_metadata(parsed_url) + else: + logger.warning(f"Unsupported service: {parsed_url.service}") + return None + + except Exception as e: + logger.error(f"Error getting metadata for {url}: {e}") + return None + + async def _get_spotify_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]: + """Get metadata from Spotify""" + try: + return UniversalMetadata( + service=MusicService.SPOTIFY, + service_id=parsed_url.id, + title=f"Spotify {parsed_url.item_type.title()}", + artist="Unknown Artist", + original_url=parsed_url.url + ) + except Exception as e: + logger.error(f"Error getting Spotify metadata: {e}") + return None + + async def _get_tidal_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]: + """Get metadata from Tidal""" + try: + import aiohttp + from bs4 import BeautifulSoup + + url = f"https://tidal.com/browse/{parsed_url.item_type}/{parsed_url.id}" + session = await self._get_session() + + async with session.get(url, headers={'User-Agent': 'Mozilla/5.0'}) as response: + if response.status == 200: + html = await response.text() + soup = BeautifulSoup(html, 'html.parser') + + title_elem = soup.find('meta', property='og:title') + artist_elem = soup.find('meta', property='og:music:artist') + image_elem = soup.find('meta', property='og:image') + + title = title_elem.get('content', '') if title_elem else '' + artist = artist_elem.get('content', '') if artist_elem else 'Unknown Artist' + image_url = image_elem.get('content', '') if image_elem else None + + return UniversalMetadata( + service=MusicService.TIDAL, + service_id=parsed_url.id, + title=title or f"Tidal {parsed_url.item_type.title()}", + artist=artist, + image_url=image_url, + original_url=parsed_url.url + ) + else: + logger.warning(f"Tidal page not found: {response.status}") + except Exception as e: + logger.error(f"Error getting Tidal metadata: {e}") + + # Fallback metadata + return UniversalMetadata( + service=MusicService.TIDAL, + service_id=parsed_url.id, + title=f"Tidal {parsed_url.item_type.title()}", + artist="Unknown Artist", + original_url=parsed_url.url + ) + + async def _get_apple_music_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]: + """Get metadata from Apple Music""" + try: + return UniversalMetadata( + service=MusicService.APPLE_MUSIC, + service_id=parsed_url.id, + title=f"Apple Music {parsed_url.item_type.title()}", + artist="Unknown Artist", + original_url=parsed_url.url + ) + except Exception as e: + logger.error(f"Error getting Apple Music metadata: {e}") + return None + + async def _get_youtube_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]: + """Get metadata from YouTube""" + try: + return UniversalMetadata( + service=MusicService.YOUTUBE, + service_id=parsed_url.id, + title=f"YouTube {parsed_url.item_type.title()}", + artist="Unknown Artist", + original_url=parsed_url.url + ) + except Exception as e: + logger.error(f"Error getting YouTube metadata: {e}") + return None + + async def _get_youtube_music_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]: + """Get metadata from YouTube Music""" + try: + return UniversalMetadata( + service=MusicService.YOUTUBE_MUSIC, + service_id=parsed_url.id, + title=f"YouTube Music {parsed_url.item_type.title()}", + artist="Unknown Artist", + original_url=parsed_url.url + ) + except Exception as e: + logger.error(f"Error getting YouTube Music metadata: {e}") + return None + + async def _get_soundcloud_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]: + """Get metadata from SoundCloud""" + try: + return UniversalMetadata( + service=MusicService.SOUNDCLOUD, + service_id=parsed_url.id, + title=f"SoundCloud {parsed_url.item_type.title()}", + artist="Unknown Artist", + original_url=parsed_url.url + ) + except Exception as e: + logger.error(f"Error getting SoundCloud metadata: {e}") + return None + + async def _get_deezer_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]: + """Get metadata from Deezer""" + try: + return UniversalMetadata( + service=MusicService.DEEZER, + service_id=parsed_url.id, + title=f"Deezer {parsed_url.item_type.title()}", + artist="Unknown Artist", + original_url=parsed_url.url + ) + except Exception as e: + logger.error(f"Error getting Deezer metadata: {e}") + return None + + async def _get_musicbrainz_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]: + """Get metadata from MusicBrainz""" + try: + return UniversalMetadata( + service=MusicService.MUSICBRAINZ, + service_id=parsed_url.id, + title=f"MusicBrainz {parsed_url.item_type.title()}", + artist="Unknown Artist", + original_url=parsed_url.url + ) + except Exception as e: + logger.error(f"Error getting MusicBrainz metadata: {e}") + return None + + async def _get_discogs_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]: + """Get metadata from Discogs""" + try: + return UniversalMetadata( + service=MusicService.DISCOGS, + service_id=parsed_url.id, + title=f"Discogs {parsed_url.item_type.title()}", + artist="Unknown Artist", + original_url=parsed_url.url + ) + except Exception as e: + logger.error(f"Error getting Discogs metadata: {e}") + return None + + def add_download(self, url: str, quality: DownloadQuality = None) -> Optional[str]: + """Add a download to the queue""" + try: + if quality is None: + quality = self.default_quality + + # Parse URL + parsed_url = self.parse_url(url) + if not parsed_url: + logger.error(f"Invalid URL: {url}") + return None + + # Create download item + download_id = str(time.time()) + item = DownloadItem( + id=download_id, + url=url, + metadata=UniversalMetadata( + service=parsed_url.service, + service_id=parsed_url.id, + title=f"{parsed_url.service.value.title()} {parsed_url.item_type.title()}", + artist="Unknown Artist", + original_url=url + ), + quality=quality, + status=DownloadStatus.PENDING + ) + + # Add to queue + self.download_queue.append(item) + + logger.info(f"Added download: {url}") + return download_id + + except Exception as e: + logger.error(f"Error adding download: {e}") + return None + + def get_download_status(self, download_id: str) -> Optional[DownloadItem]: + """Get status of a download""" + for item in self.download_queue: + if item.id == download_id: + return item + return None + + def get_all_downloads(self) -> List[DownloadItem]: + """Get all downloads""" + return self.download_queue.copy() + + +# Global instance +universal_music_downloader = UniversalMusicDownloader() diff --git a/src/swingmusic/services/universal_url_parser.py b/src/swingmusic/services/universal_url_parser.py new file mode 100644 index 00000000..1b76ab1c --- /dev/null +++ b/src/swingmusic/services/universal_url_parser.py @@ -0,0 +1,375 @@ +""" +Universal Music URL Parser for SwingMusic +Supports multiple music streaming services for universal downloading +""" + +import re +from enum import Enum +from typing import Dict, List, Optional, Tuple, Any +from dataclasses import dataclass + + +class MusicService(Enum): + SPOTIFY = "spotify" + TIDAL = "tidal" + APPLE_MUSIC = "apple_music" + YOUTUBE_MUSIC = "youtube_music" + YOUTUBE = "youtube" + SOUNDCLOUD = "soundcloud" + DEEZER = "deezer" + BANDCAMP = "bandcamp" + MUSICBRAINZ = "musicbrainz" + DISCOGS = "discogs" + + +@dataclass +class ParsedURL: + """Represents a parsed music service URL""" + service: MusicService + url: str + item_type: str # track, album, playlist, artist, etc. + id: str + metadata: Dict[str, Any] = None + + +class UniversalMusicURLParser: + """Universal parser for music service URLs""" + + def __init__(self): + self.patterns = { + MusicService.SPOTIFY: [ + r'https://open\.spotify\.com/(track|album|playlist|artist|user)/([a-zA-Z0-9]+)', + r'https://spotify\.link/([a-zA-Z0-9]+)', # Short links + ], + MusicService.TIDAL: [ + r'https://tidal\.com/(browse|track|album|playlist|artist)/(\d+)', + r'https://tidal\.com/browse/(album|track|playlist|artist)/(\d+)', + r'https://listen\.tidal\.com/(browse|track|album|playlist|artist)/(\d+)', + ], + MusicService.APPLE_MUSIC: [ + r'https://music\.apple\.com/([a-z]{2})/song/([^/]+)/(\d+)', + r'https://music\.apple\.com/([a-z]{2})/album/(.*?)/(\d+)', + r'https://music\.apple\.com/([a-z]{2})/playlist/(.*?)/pl\.(.+)', + r'https://music\.apple\.com/([a-z]{2})/artist/(.*?)/(\d+)', + ], + MusicService.YOUTUBE_MUSIC: [ + r'https://music\.youtube\.com/(watch|playlist|channel)(\?[^#]*)', + r'https://youtube\.com/music/(watch|playlist|channel)(\?[^#]*)', + ], + MusicService.YOUTUBE: [ + r'https://www\.youtube\.com/watch\?v=([a-zA-Z0-9_-]+)', + r'https://youtu\.be/([a-zA-Z0-9_-]+)', + r'https://www\.youtube\.com/playlist\?list=([a-zA-Z0-9_-]+)', + r'https://www\.youtube\.com/channel/([a-zA-Z0-9_-]+)', + r'https://www\.youtube\.com/c/([a-zA-Z0-9_-]+)', + ], + MusicService.SOUNDCLOUD: [ + r'https://soundcloud\.com/([^/]+)/([^/]+)', + r'https://soundcloud\.com/([^/]+)/sets/([^/]+)', + ], + MusicService.DEEZER: [ + r'https://www\.deezer\.com/(en|fr|de|es|it|pt|nl|ru|ja)/(track|album|playlist|artist)/(\d+)', + r'https://deezer\.page\.link/(track|album|playlist|artist)/(\d+)', + r'https://link\.deezer\.com/s/([a-zA-Z0-9_-]+)', + ], + MusicService.BANDCAMP: [ + r'https://([a-zA-Z0-9-]+)\.bandcamp\.com/(track|album)/(.+)', + r'https://bandcamp\.com/search\?q=(.+)', + ], + MusicService.MUSICBRAINZ: [ + r'https://musicbrainz\.org/(recording|release|release-group|artist)/([a-f0-9-]+)', + r'https://musicbrainz\.org/doc/([a-f0-9-]+)', # API docs + r'https://musicbrainz\.org/artist/([a-f0-9-]+)', # Direct artist links + r'https://musicbrainz\.org/release-group/([a-f0-9-]+)', # Release groups + r'https://musicbrainz\.org/label/([a-f0-9-]+)', # Record labels + r'https://musicbrainz\.org/search\?query=([^&]+)', # Search queries + ], + MusicService.DISCOGS: [ + r'https://www\.discogs\.com/(release|master|artist)/(\d+)', + ] + } + + def parse_url(self, url: str) -> Optional[ParsedURL]: + """ + Parse a music service URL and extract service, type, and ID + + Args: + url: The URL to parse + + Returns: + ParsedURL object if successful, None otherwise + """ + if not url or not isinstance(url, str): + return None + + url = url.strip() + + # Try each service pattern + for service, patterns in self.patterns.items(): + for pattern in patterns: + match = re.match(pattern, url, re.IGNORECASE) + if match: + return self._extract_service_info(service, match, url) + + return None + + def _extract_service_info(self, service: MusicService, match: re.Match, url: str) -> ParsedURL: + """Extract service-specific information from regex match""" + groups = match.groups() + + if service == MusicService.SPOTIFY: + if len(groups) == 2: + item_type, item_id = groups + return ParsedURL(service, url, item_type, item_id) + elif len(groups) == 1: # Short link + # Would need to resolve short link + return ParsedURL(service, url, 'short', groups[0]) + + elif service == MusicService.TIDAL: + item_type, item_id = groups + return ParsedURL(service, url, item_type, item_id) + + elif service == MusicService.APPLE_MUSIC: + if len(groups) >= 2: + item_type = self._map_apple_music_type(groups[0]) + item_id = groups[-1] # Last group is usually the ID + return ParsedURL(service, url, item_type, item_id, { + 'region': groups[0] if len(groups) > 2 else 'us', + 'name': groups[1] if len(groups) > 2 else '' + }) + + elif service == MusicService.YOUTUBE_MUSIC: + item_type = self._extract_youtube_type(groups[0], groups[1]) + item_id = self._extract_youtube_id(groups[1]) + return ParsedURL(service, url, item_type, item_id) + + elif service == MusicService.YOUTUBE: + if 'watch' in url: + video_id = self._extract_youtube_id(url) + return ParsedURL(service, url, 'video', video_id) + elif 'playlist' in url: + playlist_id = self._extract_youtube_playlist_id(url) + return ParsedURL(service, url, 'playlist', playlist_id) + elif 'channel' in url or '/c/' in url: + channel_id = self._extract_youtube_channel_id(url) + return ParsedURL(service, url, 'channel', channel_id) + + elif service == MusicService.SOUNDCLOUD: + if len(groups) == 2: + if groups[1] == 'sets': + item_type = 'playlist' + else: + item_type = 'track' if groups[1] else 'artist' + item_id = f"{groups[0]}/{groups[1]}" + return ParsedURL(service, url, item_type, item_id) + + elif service == MusicService.DEEZER: + if len(groups) == 2: + item_type, item_id = groups + else: + # Short link format: link.deezer.com/s/ID + item_type = 'track' # Default to track for short links + item_id = groups[0] if groups else '' + return ParsedURL(service, url, item_type, item_id) + + elif service == MusicService.BANDCAMP: + if len(groups) == 3: + item_type, item_name = groups[1], groups[2] + item_id = f"{groups[0]}/{item_type}/{item_name}" + return ParsedURL(service, url, item_type, item_id) + + elif service == MusicService.MUSICBRAINZ: + if len(groups) == 2: + item_type, item_id = groups + elif len(groups) == 1: + # Handle special cases like doc/, artist/, etc. + url_path = url.split('/')[-2] if '/' in url else '' + if 'doc/' in url: + item_type = 'doc' + elif 'artist/' in url: + item_type = 'artist' + elif 'label/' in url: + item_type = 'label' + elif 'search' in url: + item_type = 'search' + # Extract query from search URL + query_match = re.search(r'query=([^&]+)', url) + item_id = query_match.group(1) if query_match else groups[0] + else: + item_type = groups[0] if groups else 'unknown' + item_id = groups[0] if groups else '' + return ParsedURL(service, url, item_type, item_id) + + elif service == MusicService.DISCOGS: + item_type, item_id = groups + return ParsedURL(service, url, item_type, item_id) + + return ParsedURL(service, url, 'unknown', '') + + def _map_apple_music_type(self, type_str: str) -> str: + """Map Apple Music URL types to standard types""" + mapping = { + 'album': 'album', + 'playlist': 'playlist', + 'artist': 'artist', + 'song': 'song' + } + return mapping.get(type_str, 'unknown') + + def _extract_youtube_type(self, path: str, query: str) -> str: + """Extract YouTube content type from URL""" + if 'watch' in path or 'v=' in query: + return 'watch' + elif 'playlist' in path or 'list=' in query: + return 'playlist' + elif 'channel' in path: + return 'channel' + return 'unknown' + + def _extract_youtube_id(self, url: str) -> str: + """Extract YouTube video or channel ID from URL""" + # Video ID + video_match = re.search(r'[?&]v=([a-zA-Z0-9_-]+)', url) + if video_match: + return video_match.group(1) + + # Short URL + short_match = re.search(r'youtu\.be/([a-zA-Z0-9_-]+)', url) + if short_match: + return short_match.group(1) + + # Channel ID + channel_match = re.search(r'channel/([a-zA-Z0-9_-]+)', url) + if channel_match: + return channel_match.group(1) + + # Custom channel + custom_match = re.search(r'/c/([a-zA-Z0-9_-]+)', url) + if custom_match: + return custom_match.group(1) + + return '' + + def _extract_youtube_playlist_id(self, url: str) -> str: + """Extract YouTube playlist ID from URL""" + match = re.search(r'[?&]list=([a-zA-Z0-9_-]+)', url) + return match.group(1) if match else '' + + def _extract_youtube_channel_id(self, url: str) -> str: + """Extract YouTube channel ID from URL""" + # Handle both /channel/ and /c/ formats + channel_match = re.search(r'/(channel|c)/([a-zA-Z0-9_-]+)', url) + return channel_match.group(2) if channel_match else '' + + def get_supported_services(self) -> List[Dict[str, Any]]: + """Get list of supported services with their info""" + return [ + { + 'id': MusicService.SPOTIFY.value, + 'name': 'Spotify', + 'url_patterns': self.patterns[MusicService.SPOTIFY], + 'supported_types': ['track', 'album', 'playlist', 'artist'], + 'features': ['metadata', 'download', 'playlist'] + }, + { + 'id': MusicService.TIDAL.value, + 'name': 'Tidal', + 'url_patterns': self.patterns[MusicService.TIDAL], + 'supported_types': ['track', 'album', 'playlist', 'artist'], + 'features': ['metadata', 'download', 'playlist'] + }, + { + 'id': MusicService.APPLE_MUSIC.value, + 'name': 'Apple Music', + 'url_patterns': self.patterns[MusicService.APPLE_MUSIC], + 'supported_types': ['track', 'album', 'playlist', 'artist'], + 'features': ['metadata', 'download', 'playlist'] + }, + { + 'id': MusicService.YOUTUBE_MUSIC.value, + 'name': 'YouTube Music', + 'url_patterns': self.patterns[MusicService.YOUTUBE_MUSIC], + 'supported_types': ['video', 'playlist', 'channel'], + 'features': ['metadata', 'download'] + }, + { + 'id': MusicService.YOUTUBE.value, + 'name': 'YouTube', + 'url_patterns': self.patterns[MusicService.YOUTUBE], + 'supported_types': ['video', 'playlist', 'channel'], + 'features': ['metadata', 'download'] + }, + { + 'id': MusicService.SOUNDCLOUD.value, + 'name': 'SoundCloud', + 'url_patterns': self.patterns[MusicService.SOUNDCLOUD], + 'supported_types': ['track', 'playlist', 'artist'], + 'features': ['metadata', 'download'] + }, + { + 'id': MusicService.DEEZER.value, + 'name': 'Deezer', + 'url_patterns': self.patterns[MusicService.DEEZER], + 'supported_types': ['track', 'album', 'playlist', 'artist'], + 'features': ['metadata', 'download', 'playlist'] + }, + { + 'id': MusicService.BANDCAMP.value, + 'name': 'Bandcamp', + 'url_patterns': self.patterns[MusicService.BANDCAMP], + 'supported_types': ['track', 'album'], + 'features': ['metadata', 'download'] + }, + { + 'id': MusicService.MUSICBRAINZ.value, + 'name': 'MusicBrainz', + 'url_patterns': self.patterns[MusicService.MUSICBRAINZ], + 'supported_types': ['recording', 'release', 'artist'], + 'features': ['metadata'] + }, + { + 'id': MusicService.DISCOGS.value, + 'name': 'Discogs', + 'url_patterns': self.patterns[MusicService.DISCOGS], + 'supported_types': ['release', 'artist'], + 'features': ['metadata'] + } + ] + + def validate_url(self, url: str) -> bool: + """Validate if URL is from a supported service""" + return self.parse_url(url) is not None + + def get_service_from_url(self, url: str) -> Optional[MusicService]: + """Get service type from URL without full parsing""" + if not url: + return None + + url_lower = url.lower() + + if 'spotify.com' in url_lower or 'spotify.link' in url_lower: + return MusicService.SPOTIFY + elif 'tidal.com' in url_lower or 'listen.tidal.com' in url_lower: + return MusicService.TIDAL + elif 'music.apple.com' in url_lower: + return MusicService.APPLE_MUSIC + elif 'music.youtube.com' in url_lower: + return MusicService.YOUTUBE_MUSIC + elif 'youtube.com' in url_lower or 'youtu.be' in url_lower: + return MusicService.YOUTUBE + elif 'soundcloud.com' in url_lower: + return MusicService.SOUNDCLOUD + elif 'deezer.com' in url_lower or 'deezer.page.link' in url_lower: + return MusicService.DEEZER + elif 'bandcamp.com' in url_lower: + return MusicService.BANDCAMP + elif 'musicbrainz.org' in url_lower: + return MusicService.MUSICBRAINZ + elif 'discogs.com' in url_lower: + return MusicService.DISCOGS + + return None + + +# Global instance +universal_url_parser = UniversalMusicURLParser() diff --git a/src/swingmusic/services/update_tracker.py b/src/swingmusic/services/update_tracker.py new file mode 100644 index 00000000..328fd401 --- /dev/null +++ b/src/swingmusic/services/update_tracker.py @@ -0,0 +1,720 @@ +""" +Auto Track Updates & New Release Monitoring Service + +This service provides intelligent monitoring of followed artists for new releases, +with smart download queuing, priority management, and multi-channel notifications. +""" + +import asyncio +import datetime +import json +import logging +from typing import Dict, List, Optional, Tuple, Any +from dataclasses import dataclass, asdict +from enum import Enum + +from sqlalchemy import select, update, delete, insert, and_, or_ +from sqlalchemy.orm import Session + +from swingmusic.db import db +from swingmusic.models.user import User +from swingmusic.services.spotify_metadata_client import SpotifyMetadataClient +from swingmusic.services.universal_music_downloader import UniversalMusicDownloader +from swingmusic.services.library_integration import LibraryIntegrationService +from swingmusic.utils.notifications import NotificationService +from swingmusic.config import USER_DATA_DIR + +logger = logging.getLogger(__name__) + + +class FollowLevel(Enum): + CASUAL = "casual" + FOLLOWED = "followed" + FAVORITE = "favorite" + + +class ReleaseType(Enum): + ALBUM = "album" + SINGLE = "single" + EP = "ep" + COMPILATION = "compilation" + + +class DownloadPriority(Enum): + LOW = "low" + NORMAL = "normal" + HIGH = "high" + URGENT = "urgent" + + +@dataclass +class ArtistFollow: + user_id: int + artist_id: str + artist_name: str + follow_level: FollowLevel + auto_download_new_releases: bool = False + preferred_quality: str = "flac" + notification_preferences: Dict = None + follow_date: datetime.datetime = None + last_check_date: Optional[datetime.datetime] = None + + def __post_init__(self): + if self.notification_preferences is None: + self.notification_preferences = { + "in_app": True, + "push": False, + "email": False, + "discord": False + } + if self.follow_date is None: + self.follow_date = datetime.datetime.utcnow() + + +@dataclass +class ReleaseUpdate: + release_id: str + artist_id: str + artist_name: str + release_title: str + release_type: ReleaseType + release_date: datetime.date + spotify_url: str + cover_image_url: Optional[str] + total_tracks: int + popularity: int + explicit: bool = False + discovered_at: datetime.datetime = None + processed_at: Optional[datetime.datetime] = None + download_status: str = "pending" + auto_downloaded: bool = False + notification_sent: bool = False + + def __post_init__(self): + if self.discovered_at is None: + self.discovered_at = datetime.datetime.utcnow() + + +@dataclass +class UpdateNotification: + user_id: int + release_id: str + notification_type: str + sent_at: datetime.datetime + opened_at: Optional[datetime.datetime] = None + action_taken: Optional[str] = None + + +@dataclass +class UpdateMonitoringPreferences: + user_id: int + enable_artist_monitoring: bool = True + check_frequency: str = "daily" + auto_download_favorites: bool = False + auto_download_followed: bool = False + max_auto_downloads_per_week: int = 5 + quality_preference: str = "flac" + storage_limit_mb: int = 10240 + notification_channels: Dict = None + exclude_explicit: bool = False + preferred_release_types: List[str] = None + + def __post_init__(self): + if self.notification_channels is None: + self.notification_channels = { + "in_app": True, + "push": False, + "email": False, + "discord": False + } + if self.preferred_release_types is None: + self.preferred_release_types = ["album", "ep", "single"] + + +class UpdateCache: + """Simple in-memory cache for update tracking""" + + def __init__(self): + self._cache = {} + self._cache_ttl = 3600 # 1 hour + + def get_cached_releases(self, artist_id: str) -> Optional[List[Dict]]: + """Get cached releases for artist""" + cache_key = f"releases_{artist_id}" + if cache_key in self._cache: + cached_data, timestamp = self._cache[cache_key] + if datetime.datetime.now().timestamp() - timestamp < self._cache_ttl: + return cached_data + return None + + def set_cached_releases(self, artist_id: str, releases: List[Dict]): + """Cache releases for artist""" + cache_key = f"releases_{artist_id}" + self._cache[cache_key] = (releases, datetime.datetime.now().timestamp()) + + def clear_cache(self, artist_id: str = None): + """Clear cache for specific artist or all""" + if artist_id: + keys_to_remove = [k for k in self._cache.keys() if k.endswith(artist_id)] + for key in keys_to_remove: + del self._cache[key] + else: + self._cache.clear() + + +class AutoUpdateTracker: + """ + Intelligent artist update tracking service + + Features: + - Background monitoring of followed artists + - Smart download queuing with priority management + - Multi-channel notifications + - Resource-aware processing + - User preference integration + """ + + def __init__(self): + self.spotify_client = SpotifyMetadataClient() + self.downloader = UniversalMusicDownloader() + self.library_integration = LibraryIntegrationService() + self.notification_service = NotificationService() + self.update_cache = UpdateCache() + self._monitoring_tasks = [] + self._running = False + + async def start_monitoring(self): + """Start background monitoring for updates""" + if self._running: + logger.warning("Update monitoring is already running") + return + + self._running = True + logger.info("Starting update monitoring service") + + # Schedule periodic checks + self._monitoring_tasks = [ + asyncio.create_task(self._daily_artist_check()), + asyncio.create_task(self._weekly_album_check()), + asyncio.create_task(self._realtime_follow_check()), + asyncio.create_task(self._cleanup_old_data()) + ] + + async def stop_monitoring(self): + """Stop background monitoring""" + self._running = False + logger.info("Stopping update monitoring service") + + # Cancel all monitoring tasks + for task in self._monitoring_tasks: + task.cancel() + + # Wait for tasks to complete + if self._monitoring_tasks: + await asyncio.gather(*self._monitoring_tasks, return_exceptions=True) + + self._monitoring_tasks.clear() + + async def _daily_artist_check(self): + """Daily check for followed artists' new releases""" + while self._running: + try: + logger.info("Starting daily artist update check") + + with db.session() as session: + # Get all active follows + follows = self._get_followed_artists(session) + + for follow in follows: + try: + await self._check_artist_updates(follow) + except Exception as e: + logger.error(f"Error checking updates for artist {follow.artist_id}: {e}") + continue + + logger.info("Daily artist update check completed") + + except Exception as e: + logger.error(f"Error in daily artist check: {e}") + + # Wait 24 hours + await asyncio.sleep(86400) + + async def _weekly_album_check(self): + """Weekly comprehensive album check""" + while self._running: + try: + logger.info("Starting weekly album check") + + # This is a more comprehensive check that might include + # back catalog updates, reissues, etc. + with db.session() as session: + follows = self._get_followed_artists(session) + + for follow in follows: + if follow.follow_level in [FollowLevel.FAVORITE, FollowLevel.FOLLOWED]: + await self._comprehensive_artist_check(follow) + + logger.info("Weekly album check completed") + + except Exception as e: + logger.error(f"Error in weekly album check: {e}") + + # Wait 7 days + await asyncio.sleep(604800) + + async def _realtime_follow_check(self): + """Real-time check for new follows and immediate artist validation""" + while self._running: + try: + # Check for new follows that need initial processing + with db.session() as session: + new_follows = self._get_unprocessed_follows(session) + + for follow in new_follows: + await self._initial_artist_processing(follow) + + # Check every 5 minutes for new follows + await asyncio.sleep(300) + + except Exception as e: + logger.error(f"Error in realtime follow check: {e}") + await asyncio.sleep(300) + + async def _cleanup_old_data(self): + """Periodic cleanup of old data""" + while self._running: + try: + logger.info("Starting data cleanup") + + with db.session() as session: + # Clean old notifications (older than 30 days) + cutoff_date = datetime.datetime.utcnow() - datetime.timedelta(days=30) + self._cleanup_old_notifications(session, cutoff_date) + + # Clean old release updates (older than 1 year, unless downloaded) + old_cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=365) + self._cleanup_old_releases(session, old_cutoff) + + logger.info("Data cleanup completed") + + # Run cleanup weekly + await asyncio.sleep(604800) + + except Exception as e: + logger.error(f"Error in data cleanup: {e}") + await asyncio.sleep(604800) + + def _get_followed_artists(self, session: Session) -> List[ArtistFollow]: + """Get all followed artists from database""" + try: + # This would query the artist_follows table + # For now, return empty list as we'll implement the database schema next + return [] + except Exception as e: + logger.error(f"Error getting followed artists: {e}") + return [] + + def _get_unprocessed_follows(self, session: Session) -> List[ArtistFollow]: + """Get follows that haven't been processed yet""" + try: + # This would query for follows where last_check_date is NULL + return [] + except Exception as e: + logger.error(f"Error getting unprocessed follows: {e}") + return [] + + async def _check_artist_updates(self, follow: ArtistFollow): + """Check for new releases from specific artist""" + try: + logger.info(f"Checking updates for artist: {follow.artist_name} ({follow.artist_id})") + + # Get latest releases from Spotify + latest_releases = await self.spotify_client.get_artist_releases(follow.artist_id) + + # Check against local cache + cached_releases = self.update_cache.get_cached_releases(follow.artist_id) + + # Identify new releases + new_releases = self._identify_new_releases(latest_releases, cached_releases) + + if new_releases: + logger.info(f"Found {len(new_releases)} new releases for {follow.artist_name}") + await self._process_new_releases(follow, new_releases) + + # Update cache + self.update_cache.set_cached_releases(follow.artist_id, latest_releases) + + # Update last check date + await self._update_artist_check_date(follow) + + except Exception as e: + logger.error(f"Error checking updates for artist {follow.artist_id}: {e}") + + async def _comprehensive_artist_check(self, follow: ArtistFollow): + """More comprehensive check for favorite/followed artists""" + try: + # This could include checking for: + # - Back catalog additions + # - Reissues and remasters + # - Deluxe editions + # - Live albums + # - Compilations + + # For now, delegate to regular check + await self._check_artist_updates(follow) + + except Exception as e: + logger.error(f"Error in comprehensive check for {follow.artist_id}: {e}") + + async def _initial_artist_processing(self, follow: ArtistFollow): + """Initial processing when user follows an artist""" + try: + logger.info(f"Initial processing for new follow: {follow.artist_name}") + + # Get artist's complete discography + discography = await self.spotify_client.get_artist_discography(follow.artist_id) + + # Mark existing releases as "known" so we don't notify about them + self.update_cache.set_cached_releases(follow.artist_id, discography) + + # Update follow as processed + await self._update_artist_check_date(follow) + + except Exception as e: + logger.error(f"Error in initial processing for {follow.artist_id}: {e}") + + def _identify_new_releases(self, latest_releases: List[Dict], cached_releases: Optional[List[Dict]]) -> List[Dict]: + """Identify releases that are new since last check""" + if not cached_releases: + return latest_releases + + cached_ids = {release.get('id') for release in cached_releases} + new_releases = [release for release in latest_releases if release.get('id') not in cached_ids] + + return new_releases + + async def _process_new_releases(self, follow: ArtistFollow, releases: List[Dict]): + """Process newly discovered releases""" + for release_data in releases: + try: + # Create release update object + release = self._create_release_update(release_data) + + # Store in database + await self._store_release_update(release) + + # Check if should auto-download + if await self._should_auto_download(follow, release): + await self._auto_download_release(follow, release) + + # Send notification + await self._send_update_notification(follow, release) + + except Exception as e: + logger.error(f"Error processing release {release_data.get('id')}: {e}") + continue + + def _create_release_update(self, release_data: Dict) -> ReleaseUpdate: + """Create ReleaseUpdate object from Spotify data""" + return ReleaseUpdate( + release_id=release_data['id'], + artist_id=release_data['artists'][0]['id'], + artist_name=release_data['artists'][0]['name'], + release_title=release_data['name'], + release_type=ReleaseType(release_data['album_type'].lower()), + release_date=datetime.datetime.strptime(release_data['release_date'], '%Y-%m-%d').date(), + spotify_url=release_data['external_urls']['spotify'], + cover_image_url=release_data['images'][0]['url'] if release_data.get('images') else None, + total_tracks=release_data['total_tracks'], + popularity=release_data.get('popularity', 0), + explicit=release_data.get('explicit', False) + ) + + async def _store_release_update(self, release: ReleaseUpdate): + """Store release update in database""" + try: + with db.session() as session: + # This would insert into the release_updates table + # For now, just log it + logger.info(f"Storing release update: {release.release_title} by {release.artist_name}") + + except Exception as e: + logger.error(f"Error storing release update: {e}") + + async def _should_auto_download(self, follow: ArtistFollow, release: ReleaseUpdate) -> bool: + """Determine if release should be auto-downloaded""" + try: + # Get user preferences + user_prefs = await self._get_user_preferences(follow.user_id) + + # Check various conditions + conditions = [ + follow.auto_download_new_releases, + self._is_preferred_release_type(release, user_prefs), + await self._has_storage_space(user_prefs), + not self._is_explicit_blocked(release, user_prefs), + self._within_download_limits(user_prefs) + ] + + return all(conditions) + + except Exception as e: + logger.error(f"Error checking auto-download conditions: {e}") + return False + + def _is_preferred_release_type(self, release: ReleaseUpdate, user_prefs: Dict) -> bool: + """Check if release type matches user preferences""" + preferred_types = user_prefs.get('preferred_release_types', ['album', 'ep', 'single']) + return release.release_type.value in preferred_types + + async def _has_storage_space(self, user_prefs: Dict) -> bool: + """Check if there's enough storage space""" + # This would check available storage against user limits + # For now, return True + return True + + def _is_explicit_blocked(self, release: ReleaseUpdate, user_prefs: Dict) -> bool: + """Check if explicit content is blocked""" + return release.explicit and user_prefs.get('exclude_explicit', False) + + def _within_download_limits(self, user_prefs: Dict) -> bool: + """Check if within weekly download limits""" + # This would check current week's downloads against limits + # For now, return True + return True + + async def _auto_download_release(self, follow: ArtistFollow, release: ReleaseUpdate): + """Auto-download a release""" + try: + logger.info(f"Auto-downloading release: {release.release_title}") + + # Get user preferences for quality + user_prefs = await self._get_user_preferences(follow.user_id) + quality = user_prefs.get('quality_preference', follow.preferred_quality) + + # Download all tracks in release + tracks = await self.spotify_client.get_album_tracks(release.release_id) + + download_tasks = [] + for track in tracks: + task = self.downloader.download_from_url( + spotify_url=track['external_urls']['spotify'], + quality=quality, + auto_add_to_library=True, + metadata={ + 'auto_downloaded': True, + 'release_id': release.release_id, + 'artist_follow_id': follow.artist_id + } + ) + download_tasks.append(task) + + # Execute downloads concurrently + results = await asyncio.gather(*download_tasks, return_exceptions=True) + + # Mark release as downloaded + await self._mark_release_downloaded(release, results) + + logger.info(f"Auto-download completed for {release.release_title}") + + except Exception as e: + logger.error(f"Error auto-downloading release {release.release_id}: {e}") + + async def _send_update_notification(self, follow: ArtistFollow, release: ReleaseUpdate): + """Send notification for new release""" + try: + user_prefs = await self._get_user_preferences(follow.user_id) + + notification_data = { + 'type': 'new_release', + 'artist_name': release.artist_name, + 'release_title': release.release_title, + 'release_type': release.release_type.value, + 'release_date': release.release_date.isoformat(), + 'cover_image': release.cover_image_url, + 'spotify_url': release.spotify_url, + 'auto_download_enabled': follow.auto_download_new_releases, + 'actions': [ + {'label': 'Play Preview', 'action': 'preview'}, + {'label': 'Download Now', 'action': 'download'}, + {'label': 'Add to Queue', 'action': 'queue'} + ] + } + + # Send through enabled channels + if user_prefs.get('notification_channels', {}).get('in_app', True): + await self.notification_service.send_in_app_notification(follow.user_id, notification_data) + + if user_prefs.get('notification_channels', {}).get('push', False): + await self.notification_service.send_push_notification(follow.user_id, notification_data) + + # Mark notification as sent + await self._mark_notification_sent(release) + + except Exception as e: + logger.error(f"Error sending notification for release {release.release_id}: {e}") + + async def _get_user_preferences(self, user_id: int) -> Dict: + """Get user's update monitoring preferences""" + try: + with db.session() as session: + # This would query update_monitoring_preferences table + # For now, return defaults + return { + 'enable_artist_monitoring': True, + 'check_frequency': 'daily', + 'auto_download_favorites': False, + 'auto_download_followed': False, + 'max_auto_downloads_per_week': 5, + 'quality_preference': 'flac', + 'storage_limit_mb': 10240, + 'notification_channels': { + 'in_app': True, + 'push': False, + 'email': False, + 'discord': False + }, + 'exclude_explicit': False, + 'preferred_release_types': ['album', 'ep', 'single'] + } + + except Exception as e: + logger.error(f"Error getting user preferences for {user_id}: {e}") + return {} + + async def _update_artist_check_date(self, follow: ArtistFollow): + """Update the last check date for an artist follow""" + try: + follow.last_check_date = datetime.datetime.utcnow() + # This would update the artist_follows table + logger.debug(f"Updated check date for {follow.artist_name}") + + except Exception as e: + logger.error(f"Error updating check date for {follow.artist_id}: {e}") + + async def _mark_release_downloaded(self, release: ReleaseUpdate, results: List): + """Mark a release as downloaded in database""" + try: + release.download_status = "completed" + release.auto_downloaded = True + release.processed_at = datetime.datetime.utcnow() + # This would update the release_updates table + logger.info(f"Marked release {release.release_id} as downloaded") + + except Exception as e: + logger.error(f"Error marking release as downloaded: {e}") + + async def _mark_notification_sent(self, release: ReleaseUpdate): + """Mark notification as sent for release""" + try: + release.notification_sent = True + # This would update the release_updates table + logger.debug(f"Marked notification sent for release {release.release_id}") + + except Exception as e: + logger.error(f"Error marking notification sent: {e}") + + def _cleanup_old_notifications(self, session: Session, cutoff_date: datetime.datetime): + """Clean up old notifications""" + try: + # This would delete from update_notifications table + logger.debug(f"Cleaning up notifications older than {cutoff_date}") + + except Exception as e: + logger.error(f"Error cleaning up old notifications: {e}") + + def _cleanup_old_releases(self, session: Session, cutoff_date: datetime.datetime): + """Clean up old releases""" + try: + # This would delete old releases from release_updates table + # unless they were downloaded + logger.debug(f"Cleaning up releases older than {cutoff_date}") + + except Exception as e: + logger.error(f"Error cleaning up old releases: {e}") + + # Public API methods + + async def follow_artist(self, follow_data: Dict) -> bool: + """Follow an artist for update tracking""" + try: + follow = ArtistFollow( + user_id=follow_data['user_id'], + artist_id=follow_data['artist_id'], + artist_name=follow_data['artist_name'], + follow_level=FollowLevel(follow_data.get('follow_level', 'followed')), + auto_download_new_releases=follow_data.get('auto_download', False), + preferred_quality=follow_data.get('preferred_quality', 'flac') + ) + + # Store in database + with db.session() as session: + # This would insert into artist_follows table + logger.info(f"User {follow.user_id} followed artist: {follow.artist_name}") + return True + + except Exception as e: + logger.error(f"Error following artist: {e}") + return False + + async def unfollow_artist(self, user_id: int, artist_id: str) -> bool: + """Unfollow an artist""" + try: + with db.session() as session: + # This would delete from artist_follows table + logger.info(f"User {user_id} unfollowed artist: {artist_id}") + return True + + except Exception as e: + logger.error(f"Error unfollowing artist: {e}") + return False + + async def get_user_updates(self, user_id: int, limit: int = 20) -> List[Dict]: + """Get recent updates for a user""" + try: + with db.session() as session: + # This would query release_updates joined with artist_follows + # For now, return empty list + return [] + + except Exception as e: + logger.error(f"Error getting user updates: {e}") + return [] + + async def get_user_settings(self, user_id: int) -> Dict: + """Get user's update tracking settings""" + return await self._get_user_preferences(user_id) + + async def update_user_settings(self, user_id: int, settings: Dict) -> bool: + """Update user's update tracking settings""" + try: + with db.session() as session: + # This would update update_monitoring_preferences table + logger.info(f"Updated settings for user {user_id}") + return True + + except Exception as e: + logger.error(f"Error updating user settings: {e}") + return False + + async def get_user_stats(self, user_id: int) -> Dict: + """Get user's update tracking statistics""" + try: + with db.session() as session: + # This would calculate statistics from various tables + return { + 'followed_artists': 0, + 'new_releases': 0, + 'pending_downloads': 0, + 'auto_downloaded': 0, + 'last_check': None + } + + except Exception as e: + logger.error(f"Error getting user stats: {e}") + return {} + + +# Singleton instance +update_tracker = AutoUpdateTracker() diff --git a/src/swingmusic/start_swingmusic.py b/src/swingmusic/start_swingmusic.py index 5b6a0534..7f88a407 100644 --- a/src/swingmusic/start_swingmusic.py +++ b/src/swingmusic/start_swingmusic.py @@ -5,6 +5,7 @@ from swingmusic.plugins.register import register_plugins from swingmusic.setup import load_into_mem, run_setup from swingmusic.start_info_logger import log_startup_info from swingmusic.utils.threading import background +from swingmusic.services.spotify_downloader import spotify_downloader import setproctitle @@ -90,6 +91,10 @@ def start_swingmusic(host: str, port: int): setproctitle.setproctitle(f"swingmusic {host}:{port}") start_cron_jobs() + + # Start Spotify downloader service + spotify_downloader.start() + print("Spotify downloader service started") app = app_builder.build() diff --git a/src/swingmusic/utils/__init__.py b/src/swingmusic/utils/__init__.py index cf6a5688..a1862e96 100644 --- a/src/swingmusic/utils/__init__.py +++ b/src/swingmusic/utils/__init__.py @@ -1,4 +1,5 @@ import locale +import re from typing import Iterable, TypeVar T = TypeVar("T") @@ -21,6 +22,24 @@ def flatten(list_: Iterable[list[T]]) -> list[T]: return [item for sublist in list_ for item in sublist] +def create_valid_filename(filename: str) -> str: + """ + Create a valid filename by removing invalid characters. + """ + # Remove invalid characters for filenames + invalid_chars = r'[<>:"/\\|?*]' + filename = re.sub(invalid_chars, '_', filename) + + # Remove leading/trailing spaces and dots + filename = filename.strip(' .') + + # Ensure filename is not empty + if not filename: + filename = "unnamed" + + return filename + + class classproperty(property): """ A class property decorator.