mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-05 04:53:01 +00:00
Reorganize repository structure for better organization
- Move backend code to swingmusic/ folder - Move client applications to root level (swingmusic-android, swingmusic-desktop, swingmusic-webclient) - Remove intermediate backend/ and clients/ folders - Update README with new folder structure and setup instructions - Clean and organized repository layout
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,928 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,445 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,452 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,455 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,228 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,283 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,296 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,732 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,904 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,315 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,785 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,839 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,74 +0,0 @@
|
||||
"""
|
||||
Spotify Downloader Service
|
||||
Handles downloading of music from Spotify URLs
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DownloadSource:
|
||||
"""Represents a download source"""
|
||||
|
||||
def __init__(self, source_type: str, url: str, metadata: Dict[str, Any]):
|
||||
self.source_type = source_type
|
||||
self.url = url
|
||||
self.metadata = metadata
|
||||
|
||||
def spotify_downloader(url: str) -> Optional[DownloadSource]:
|
||||
"""
|
||||
Download music from a Spotify URL (legacy function name)
|
||||
|
||||
Args:
|
||||
url: The URL to download from
|
||||
|
||||
Returns:
|
||||
DownloadSource object if successful, None otherwise
|
||||
"""
|
||||
return download_from_url(url)
|
||||
|
||||
def download_from_url(url: str) -> Optional[DownloadSource]:
|
||||
"""
|
||||
Download music from a supported URL
|
||||
|
||||
Args:
|
||||
url: The URL to download from
|
||||
|
||||
Returns:
|
||||
DownloadSource object if successful, None otherwise
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
|
||||
if 'spotify.com' in parsed.netloc or 'open.spotify.com' in parsed.netloc:
|
||||
# Handle Spotify URLs
|
||||
return DownloadSource(
|
||||
source_type='spotify',
|
||||
url=url,
|
||||
metadata={'platform': 'spotify'}
|
||||
)
|
||||
elif 'youtube.com' in parsed.netloc or 'youtu.be' in parsed.netloc:
|
||||
# Handle YouTube URLs
|
||||
return DownloadSource(
|
||||
source_type='youtube',
|
||||
url=url,
|
||||
metadata={'platform': 'youtube'}
|
||||
)
|
||||
else:
|
||||
# Generic URL handler
|
||||
return DownloadSource(
|
||||
source_type='generic',
|
||||
url=url,
|
||||
metadata={'platform': 'generic'}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing URL {url}: {e}")
|
||||
return None
|
||||
|
||||
def get_supported_platforms() -> list:
|
||||
"""Get list of supported platforms"""
|
||||
return ['spotify', 'youtube', 'generic']
|
||||
@@ -1,577 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,343 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,375 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,720 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user