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:
Tomas Dvorak
2026-03-17 22:34:34 +01:00
parent 17e859dd2f
commit 4c04287800
206 changed files with 14 additions and 7 deletions
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()
-904
View File
@@ -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()
-785
View File
@@ -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()
-720
View File
@@ -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()