mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-03 20:13:02 +00:00
Add comprehensive backend services and API enhancements
- Complete Spotify integration with downloader and settings - Advanced UX features and audio quality management - Enhanced search capabilities and mobile offline support - Music catalog browser and recap features - Universal downloader and upload functionality - Update tracking system with database models and migrations - Comprehensive service layer architecture - Enhanced lyrics API and streaming capabilities - Extended application builder and startup configuration - New logging infrastructure and services directory
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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/<artist_id>', 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/<album_id>', 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/<int:user_id>', 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")
|
||||
@@ -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"}
|
||||
|
||||
@@ -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/<device_id>', 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/<device_id>/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/<device_id>/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/<device_id>/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/<device_id>/sync-playlist/<playlist_id>', 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/<device_id>/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/<device_id>/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/<device_id>/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/<device_id>/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/<device_id>/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
|
||||
@@ -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/<artist_id>/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/<artist_id>/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/<artist_id>', 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/<album_id>', 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/<int:user_id>', 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
|
||||
@@ -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
|
||||
|
||||
@@ -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/<int:year>', 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/<int:year>', 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/<int:year>', 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/<int:year>', 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/<int:year>', 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/<token>', 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/<int:year1>/<int:year2>', 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)
|
||||
@@ -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/<item_id>', 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/<item_id>', 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
|
||||
@@ -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
|
||||
@@ -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("/<trackhash>/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,15 +108,166 @@ 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("/<trackhash>/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
|
||||
|
||||
|
||||
|
||||
@@ -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/<item_id>/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/<item_id>/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/<service_name>/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/<service_name>/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/<service_name>/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")
|
||||
@@ -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/<release_id>', 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/<artist_id>/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/<artist_id>', 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/<release_id>', 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/<release_id>/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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"<ArtistFollow(user_id={self.user_id}, artist='{self.artist_name}')>"
|
||||
|
||||
|
||||
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"<ReleaseUpdate(title='{self.release_title}', artist='{self.artist_name}')>"
|
||||
|
||||
|
||||
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"<UpdateNotification(user_id={self.user_id}, type='{self.notification_type}')>"
|
||||
|
||||
|
||||
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"<UpdateMonitoringPreferences(user_id={self.user_id})>"
|
||||
|
||||
|
||||
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"<DownloadTask(track='{self.track_title}', status='{self.status}')>"
|
||||
|
||||
|
||||
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"<ArtistFollowHistory(user_id={self.user_id}, action='{self.action}')>"
|
||||
|
||||
|
||||
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"<ReleaseUpdateHistory(release='{self.release_title}', action='{self.action}')>"
|
||||
|
||||
|
||||
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"<UpdateTrackingStats(user_id={self.user_id}, date={self.stat_date})>"
|
||||
|
||||
|
||||
# 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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -91,6 +92,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()
|
||||
|
||||
log_startup_info(host, port)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user