Files
swingmusic-extended/src/swingmusic/db/spotify.py
T
Tomas Dvorak 38f1981283 Move backend files to root level for cleaner GitHub display
- Move all backend files from swingmusic/ to root level
- Backend files now display directly on GitHub repository page
- Keep client applications as submodules (swingmusic-android, swingmusic-desktop, swingmusic-webclient)
- Update README to reflect new structure (no cd swingmusic needed)
- Cleaner, more professional GitHub repository layout

Files moved to root:
- src/ (main source code)
- pyproject.toml, requirements.txt, run.py
- swingmusic.spec, uv.lock, version.txt
- services/

Result: GitHub shows backend files directly while maintaining organized structure
2026-03-17 22:37:49 +01:00

1018 lines
36 KiB
Python

"""
Database models for Spotify downloader functionality
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
JSON,
Boolean,
ForeignKey,
Integer,
String,
Text,
Float,
and_,
delete,
func,
insert,
select,
update,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from swingmusic.db.engine import DbEngine
from swingmusic.db import Base
class SpotifyDownloadTable(Base):
__tablename__ = "spotify_downloads"
id: Mapped[int] = mapped_column(primary_key=True)
spotify_url: Mapped[str] = mapped_column(String(500), unique=True, nullable=False, index=True)
spotify_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
item_type: Mapped[str] = mapped_column(String(20), nullable=False) # track, album, playlist
title: Mapped[str] = mapped_column(String(500), nullable=False)
artist: Mapped[str] = mapped_column(String(500), nullable=False)
album: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
image_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
release_date: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
# Download settings
quality: Mapped[str] = mapped_column(String(20), nullable=False, default='flac')
source: Mapped[str] = mapped_column(String(20), nullable=False, default='tidal')
output_dir: Mapped[str] = mapped_column(String(1000), nullable=False)
# Download status
status: Mapped[str] = mapped_column(String(20), nullable=False, default='pending')
progress: Mapped[int] = mapped_column(Integer, default=0)
file_path: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True)
file_size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Error handling
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
retry_count: Mapped[int] = mapped_column(Integer, default=0)
max_retries: Mapped[int] = mapped_column(Integer, default=3)
# Metadata
metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
# Timestamps
created_at: Mapped[float] = mapped_column(Float, nullable=False)
started_at: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
completed_at: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
updated_at: Mapped[float] = mapped_column(Float, nullable=False)
# User association
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id"), nullable=True)
@classmethod
def create(cls, data: dict):
"""Create a new Spotify download record"""
if 'created_at' not in data:
data['created_at'] = datetime.now().timestamp()
if 'updated_at' not in data:
data['updated_at'] = datetime.now().timestamp()
return cls.insert_one(data)
@classmethod
def get_by_id(cls, download_id: int):
"""Get download by ID"""
result = cls.execute(select(cls).where(cls.id == download_id))
res = next(result).scalar()
return res
@classmethod
def get_by_spotify_id(cls, spotify_id: str):
"""Get download by Spotify ID"""
result = cls.execute(select(cls).where(cls.spotify_id == spotify_id))
res = next(result).scalar()
return res
@classmethod
def get_by_url(cls, spotify_url: str):
"""Get download by Spotify URL"""
result = cls.execute(select(cls).where(cls.spotify_url == spotify_url))
res = next(result).scalar()
return res
@classmethod
def get_pending_downloads(cls, limit: int = 50):
"""Get pending downloads"""
result = cls.execute(
select(cls)
.where(cls.status == 'pending')
.order_by(cls.created_at)
.limit(limit)
)
return [item for item in next(result).scalars()]
@classmethod
def get_active_downloads(cls):
"""Get currently active downloads"""
result = cls.execute(
select(cls)
.where(cls.status.in_(['downloading', 'processing']))
.order_by(cls.started_at)
)
return [item for item in next(result).scalars()]
@classmethod
def get_download_history(cls, user_id: Optional[int] = None, limit: int = 100, offset: int = 0):
"""Get download history with pagination"""
query = select(cls).where(cls.status.in_(['completed', 'failed', 'cancelled']))
if user_id:
query = query.where(cls.user_id == user_id)
query = query.order_by(cls.created_at.desc()).offset(offset).limit(limit)
result = cls.execute(query)
return [item for item in next(result).scalars()]
@classmethod
def update_status(cls, download_id: int, status: str, **kwargs):
"""Update download status and related fields"""
update_data = {
'status': status,
'updated_at': datetime.now().timestamp()
}
update_data.update(kwargs)
return cls.execute(
update(cls)
.where(cls.id == download_id)
.values(update_data),
commit=True
)
@classmethod
def update_progress(cls, download_id: int, progress: int):
"""Update download progress"""
return cls.execute(
update(cls)
.where(cls.id == download_id)
.values({
'progress': progress,
'updated_at': datetime.now().timestamp()
}),
commit=True
)
@classmethod
def increment_retry(cls, download_id: int):
"""Increment retry count"""
return cls.execute(
update(cls)
.where(cls.id == download_id)
.values({
'retry_count': cls.retry_count + 1,
'updated_at': datetime.now().timestamp()
}),
commit=True
)
@classmethod
def delete_completed(cls, older_than_days: int = 30):
"""Delete completed downloads older than specified days"""
cutoff_time = datetime.now().timestamp() - (older_than_days * 24 * 60 * 60)
return cls.execute(
delete(cls)
.where(
and_(
cls.status.in_(['completed', 'failed', 'cancelled']),
cls.completed_at < cutoff_time
)
),
commit=True
)
@classmethod
def get_statistics(cls):
"""Get download statistics"""
result = cls.execute(
select(
cls.status,
func.count(cls.id).label('count'),
func.avg(cls.duration_ms).label('avg_duration')
)
.group_by(cls.status)
)
stats = {}
for row in next(result):
stats[row.status] = {
'count': row.count,
'avg_duration_ms': row.avg_duration
}
return stats
class SpotifyDownloadSourceTable(Base):
__tablename__ = "spotify_download_sources"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
config: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
created_at: Mapped[float] = mapped_column(Float, nullable=False)
updated_at: Mapped[float] = mapped_column(Float, nullable=False)
@classmethod
def get_active_sources(cls):
"""Get all active download sources ordered by priority"""
result = cls.execute(
select(cls)
.where(cls.is_active == True)
.order_by(cls.priority)
)
return [item for item in next(result).scalars()]
@classmethod
def get_by_name(cls, name: str):
"""Get source by name"""
result = cls.execute(select(cls).where(cls.name == name))
res = next(result).scalar()
return res
@classmethod
def update_source(cls, name: str, **kwargs):
"""Update source configuration"""
kwargs['updated_at'] = datetime.now().timestamp()
return cls.execute(
update(cls)
.where(cls.name == name)
.values(kwargs),
commit=True
)
class SpotifyDownloadQueueTable(Base):
__tablename__ = "spotify_download_queue"
id: Mapped[int] = mapped_column(primary_key=True)
download_id: Mapped[int] = mapped_column(ForeignKey("spotify_downloads.id"), nullable=False)
priority: Mapped[int] = mapped_column(Integer, default=0)
position: Mapped[int] = mapped_column(Integer, nullable=False)
added_at: Mapped[float] = mapped_column(Float, nullable=False)
started_at: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
# Relationship to download
download = relationship("SpotifyDownloadTable", backref="queue_items")
@classmethod
def add_to_queue(cls, download_id: int, priority: int = 0):
"""Add download to queue"""
# Get current max position
result = cls.execute(select(func.max(cls.position)))
max_position = next(result).scalar() or 0
data = {
'download_id': download_id,
'priority': priority,
'position': max_position + 1,
'added_at': datetime.now().timestamp()
}
return cls.insert_one(data)
@classmethod
def get_next_item(cls):
"""Get next item from queue"""
result = cls.execute(
select(cls)
.join(SpotifyDownloadTable)
.where(
and_(
SpotifyDownloadTable.status == 'pending',
cls.started_at.is_(None)
)
)
.order_by(cls.priority.desc(), cls.position)
.limit(1)
)
res = next(result).scalar()
return res
@classmethod
def remove_from_queue(cls, download_id: int):
"""Remove item from queue"""
return cls.execute(
delete(cls).where(cls.download_id == download_id),
commit=True
)
@classmethod
def get_queue_length(cls):
"""Get current queue length"""
result = cls.execute(
select(func.count(cls.id))
.join(SpotifyDownloadTable)
.where(SpotifyDownloadTable.status == 'pending')
)
return next(result).scalar() or 0
# Create default download sources
def create_default_sources():
"""Create default download sources if they don't exist"""
default_sources = [
{
'name': 'tidal',
'display_name': 'Tidal',
'priority': 1,
'is_active': True,
'config': {
'quality_preference': ['lossless', 'high', 'normal'],
'formats': ['flac', 'mp3']
}
},
{
'name': 'qobuz',
'display_name': 'Qobuz',
'priority': 2,
'is_active': True,
'config': {
'quality_preference': ['lossless', 'high', 'normal'],
'formats': ['flac', 'mp3']
}
},
{
'name': 'amazon',
'display_name': 'Amazon Music',
'priority': 3,
'is_active': False, # Disabled by default
'config': {
'quality_preference': ['high', 'normal'],
'formats': ['mp3', 'aac']
}
}
]
current_time = datetime.now().timestamp()
for source_data in default_sources:
source_data['created_at'] = current_time
source_data['updated_at'] = current_time
existing = SpotifyDownloadSourceTable.get_by_name(source_data['name'])
if not existing:
SpotifyDownloadSourceTable.insert_one(source_data)
# Add execute method (assuming it exists in the base class)
# This would need to be implemented based on the existing database pattern
for table_class in [SpotifyDownloadTable, SpotifyDownloadSourceTable, SpotifyDownloadQueueTable]:
if not hasattr(table_class, 'execute'):
@classmethod
def execute_method(cls, query, commit=False):
engine = DbEngine()
with engine.session() as session:
result = session.execute(query)
if commit:
session.commit()
return result
table_class.execute = execute_method
table_class.insert_one = lambda data: table_class.execute(insert(table_class).values(data), commit=True)
class GlobalCatalogCacheTable(Base):
__tablename__ = "global_catalog_cache"
id: Mapped[int] = mapped_column(primary_key=True)
spotify_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
item_type: Mapped[str] = mapped_column(String(50), nullable=False) # track, album, artist, playlist, search, artist_top_tracks, etc.
title: Mapped[str] = mapped_column(String(500), nullable=False)
artist: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
album: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
popularity: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
preview_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
image_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
release_date: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
explicit: Mapped[bool] = mapped_column(Boolean, default=False)
data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) # Full metadata JSON
cached_at: Mapped[float] = mapped_column(Float, nullable=False)
expires_at: Mapped[float] = mapped_column(Float, nullable=False)
@classmethod
def create(cls, data: dict):
"""Create a new catalog cache entry"""
if 'cached_at' not in data:
data['cached_at'] = datetime.now().timestamp()
return cls.insert_one(data)
@classmethod
def get_by_spotify_id(cls, spotify_id: str, item_type: str = None):
"""Get cached item by Spotify ID and optionally type"""
query = select(cls).where(cls.spotify_id == spotify_id)
if item_type:
query = query.where(cls.item_type == item_type)
query = query.where(cls.expires_at > datetime.now().timestamp())
query = query.order_by(cls.cached_at.desc())
result = cls.execute(query)
res = next(result).scalar()
return res
@classmethod
def get_expired_entries(cls):
"""Get all expired cache entries"""
result = cls.execute(
select(cls).where(cls.expires_at <= datetime.now().timestamp())
)
return [item for item in next(result).scalars()]
@classmethod
def delete_expired(cls):
"""Delete all expired cache entries"""
return cls.execute(
delete(cls).where(cls.expires_at <= datetime.now().timestamp()),
commit=True
)
@classmethod
def search_cached(cls, query: str, item_types: list = None, limit: int = 20):
"""Search cached items by title or artist"""
query_filter = select(cls).where(
and_(
cls.expires_at > datetime.now().timestamp(),
or_(
cls.title.contains(query),
cls.artist.contains(query)
)
)
)
if item_types:
query_filter = query_filter.where(cls.item_type.in_(item_types))
query_filter = query_filter.order_by(cls.popularity.desc()).limit(limit)
result = cls.execute(query_filter)
return [item for item in next(result).scalars()]
@classmethod
def get_cache_stats(cls):
"""Get cache statistics"""
result = cls.execute(
select(
cls.item_type,
func.count(cls.id).label('count'),
func.avg(cls.popularity).label('avg_popularity')
)
.where(cls.expires_at > datetime.now().timestamp())
.group_by(cls.item_type)
)
stats = {}
for row in next(result):
stats[row.item_type] = {
'count': row.count,
'avg_popularity': row.avg_popularity
}
return stats
class UserCatalogPreferencesTable(Base):
__tablename__ = "user_catalog_preferences"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), nullable=False, unique=True)
show_explicit: Mapped[bool] = mapped_column(Boolean, default=True)
default_quality: Mapped[str] = mapped_column(String(20), default='flac')
auto_download: Mapped[bool] = mapped_column(Boolean, default=False)
show_suggestions: Mapped[bool] = mapped_column(Boolean, default=True)
preferred_genres: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
excluded_genres: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
max_search_results: Mapped[int] = mapped_column(Integer, default=20)
max_top_tracks: Mapped[int] = mapped_column(Integer, default=15)
max_albums_per_artist: Mapped[int] = mapped_column(Integer, default=20)
max_trending_results: Mapped[int] = mapped_column(Integer, default=20)
max_recommendations: Mapped[int] = mapped_column(Integer, default=20)
preferred_markets: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
cache_ttl_preference: Mapped[int] = mapped_column(Integer, default=3600) # 1 hour
created_at: Mapped[float] = mapped_column(Float, nullable=False)
updated_at: Mapped[float] = mapped_column(Float, nullable=False)
@classmethod
def get_or_create(cls, user_id: int):
"""Get user preferences or create with defaults"""
result = cls.execute(select(cls).where(cls.user_id == user_id))
existing = next(result).scalar()
if existing:
return existing
# Create with defaults
current_time = datetime.now().timestamp()
default_prefs = {
'user_id': user_id,
'show_explicit': True,
'default_quality': 'flac',
'auto_download': False,
'show_suggestions': True,
'max_search_results': 20,
'max_top_tracks': 15,
'max_albums_per_artist': 20,
'max_trending_results': 20,
'max_recommendations': 20,
'preferred_markets': ['US'],
'cache_ttl_preference': 3600,
'created_at': current_time,
'updated_at': current_time
}
return cls.insert_one(default_prefs)
@classmethod
def update_preferences(cls, user_id: int, preferences: dict):
"""Update user catalog preferences"""
preferences['updated_at'] = datetime.now().timestamp()
return cls.execute(
update(cls)
.where(cls.user_id == user_id)
.values(preferences),
commit=True
)
def save(self):
"""Save current preferences state"""
self.updated_at = datetime.now().timestamp()
return self.execute(
update(self.__class__)
.where(self.__class__.id == self.id)
.values({
'show_explicit': self.show_explicit,
'default_quality': self.default_quality,
'auto_download': self.auto_download,
'show_suggestions': self.show_suggestions,
'preferred_genres': self.preferred_genres,
'excluded_genres': self.excluded_genres,
'max_search_results': self.max_search_results,
'max_top_tracks': self.max_top_tracks,
'max_albums_per_artist': self.max_albums_per_artist,
'max_trending_results': self.max_trending_results,
'max_recommendations': self.max_recommendations,
'preferred_markets': self.preferred_markets,
'cache_ttl_preference': self.cache_ttl_preference,
'updated_at': self.updated_at
}),
commit=True
)
class UniversalDownloadTable(Base):
__tablename__ = "universal_downloads"
id: Mapped[int] = mapped_column(primary_key=True)
url: Mapped[str] = mapped_column(String(1000), nullable=False, index=True)
service: Mapped[str] = mapped_column(String(50), nullable=False, index=True) # spotify, tidal, apple_music, etc.
service_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
item_type: Mapped[str] = mapped_column(String(20), nullable=False) # track, album, playlist, artist
title: Mapped[str] = mapped_column(String(500), nullable=False)
artist: Mapped[str] = mapped_column(String(500), nullable=False)
album: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
image_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
release_date: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
# Download settings
quality: Mapped[str] = mapped_column(String(20), nullable=False, default='high')
output_dir: Mapped[str] = mapped_column(String(1000), nullable=False)
# Download status
status: Mapped[str] = mapped_column(String(20), nullable=False, default='pending')
progress: Mapped[int] = mapped_column(Integer, default=0)
file_path: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True)
file_size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Error handling
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
retry_count: Mapped[int] = mapped_column(Integer, default=0)
max_retries: Mapped[int] = mapped_column(Integer, default=3)
# Metadata
metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
# Timestamps
created_at: Mapped[float] = mapped_column(Float, nullable=False)
started_at: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
completed_at: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
updated_at: Mapped[float] = mapped_column(Float, nullable=False)
# User association
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id"), nullable=True)
@classmethod
def create(cls, data: dict):
"""Create a new universal download record"""
if 'created_at' not in data:
data['created_at'] = datetime.now().timestamp()
if 'updated_at' not in data:
data['updated_at'] = datetime.now().timestamp()
return cls.insert_one(data)
@classmethod
def get_by_id(cls, download_id: int):
"""Get download by ID"""
result = cls.execute(select(cls).where(cls.id == download_id))
res = next(result).scalar()
return res
@classmethod
def get_by_service_id(cls, service: str, service_id: str):
"""Get download by service and service ID"""
result = cls.execute(
select(cls)
.where(and_(cls.service == service, cls.service_id == service_id))
)
res = next(result).scalar()
return res
@classmethod
def get_by_url(cls, url: str):
"""Get download by URL"""
result = cls.execute(select(cls).where(cls.url == url))
res = next(result).scalar()
return res
@classmethod
def get_pending_downloads(cls, limit: int = 50):
"""Get pending downloads"""
result = cls.execute(
select(cls)
.where(cls.status == 'pending')
.order_by(cls.created_at)
.limit(limit)
)
return [item for item in next(result).scalars()]
@classmethod
def get_active_downloads(cls):
"""Get currently active downloads"""
result = cls.execute(
select(cls)
.where(cls.status.in_(['downloading', 'processing']))
.order_by(cls.started_at)
)
return [item for item in next(result).scalars()]
@classmethod
def get_download_history(cls, user_id: Optional[int] = None, limit: int = 100, offset: int = 0):
"""Get download history with pagination"""
query = select(cls).where(cls.status.in_(['completed', 'failed', 'cancelled']))
if user_id:
query = query.where(cls.user_id == user_id)
query = query.order_by(cls.created_at.desc()).offset(offset).limit(limit)
result = cls.execute(query)
return [item for item in next(result).scalars()]
@classmethod
def get_downloads_by_service(cls, service: str, limit: int = 50):
"""Get downloads by service"""
result = cls.execute(
select(cls)
.where(cls.service == service)
.order_by(cls.created_at.desc())
.limit(limit)
)
return [item for item in next(result).scalars()]
@classmethod
def update_status(cls, download_id: int, status: str, **kwargs):
"""Update download status and related fields"""
update_data = {
'status': status,
'updated_at': datetime.now().timestamp()
}
update_data.update(kwargs)
return cls.execute(
update(cls)
.where(cls.id == download_id)
.values(update_data),
commit=True
)
@classmethod
def update_progress(cls, download_id: int, progress: int):
"""Update download progress"""
return cls.execute(
update(cls)
.where(cls.id == download_id)
.values({
'progress': progress,
'updated_at': datetime.now().timestamp()
}),
commit=True
)
@classmethod
def increment_retry(cls, download_id: int):
"""Increment retry count"""
return cls.execute(
update(cls)
.where(cls.id == download_id)
.values({
'retry_count': cls.retry_count + 1,
'updated_at': datetime.now().timestamp()
}),
commit=True
)
@classmethod
def delete_completed(cls, older_than_days: int = 30):
"""Delete completed downloads older than specified days"""
cutoff_time = datetime.now().timestamp() - (older_than_days * 24 * 60 * 60)
return cls.execute(
delete(cls)
.where(
and_(
cls.status.in_(['completed', 'failed', 'cancelled']),
cls.completed_at < cutoff_time
)
),
commit=True
)
@classmethod
def get_statistics(cls):
"""Get download statistics"""
result = cls.execute(
select(
cls.service,
cls.status,
func.count(cls.id).label('count'),
func.avg(cls.duration_ms).label('avg_duration')
)
.group_by(cls.service, cls.status)
)
stats = {}
for row in next(result):
service = row.service
if service not in stats:
stats[service] = {}
stats[service][row.status] = {
'count': row.count,
'avg_duration_ms': row.avg_duration
}
return stats
class UniversalDownloadSourceTable(Base):
__tablename__ = "universal_download_sources"
id: Mapped[int] = mapped_column(primary_key=True)
service: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) # spotify, tidal, apple_music, etc.
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
supported_types: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) # track, album, playlist, artist
features: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) # metadata, download, playlist
config: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
created_at: Mapped[float] = mapped_column(Float, nullable=False)
updated_at: Mapped[float] = mapped_column(Float, nullable=False)
@classmethod
def get_enabled_sources(cls):
"""Get all enabled download sources ordered by priority"""
result = cls.execute(
select(cls)
.where(cls.enabled == True)
.order_by(cls.priority)
)
return [item for item in next(result).scalars()]
@classmethod
def get_by_service(cls, service: str):
"""Get source by service name"""
result = cls.execute(select(cls).where(cls.service == service))
res = next(result).scalar()
return res
@classmethod
def update_source(cls, service: str, **kwargs):
"""Update source configuration"""
kwargs['updated_at'] = datetime.now().timestamp()
return cls.execute(
update(cls)
.where(cls.service == service)
.values(kwargs),
commit=True
)
class UniversalDownloadQueueTable(Base):
__tablename__ = "universal_download_queue"
id: Mapped[int] = mapped_column(primary_key=True)
download_id: Mapped[int] = mapped_column(ForeignKey("universal_downloads.id"), nullable=False)
priority: Mapped[int] = mapped_column(Integer, default=0)
position: Mapped[int] = mapped_column(Integer, nullable=False)
added_at: Mapped[float] = mapped_column(Float, nullable=False)
started_at: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
# Relationship to download
download = relationship("UniversalDownloadTable", backref="queue_items")
@classmethod
def add_to_queue(cls, download_id: int, priority: int = 0):
"""Add download to queue"""
# Get current max position
result = cls.execute(select(func.max(cls.position)))
max_position = next(result).scalar() or 0
data = {
'download_id': download_id,
'priority': priority,
'position': max_position + 1,
'added_at': datetime.now().timestamp()
}
return cls.insert_one(data)
@classmethod
def get_next_item(cls):
"""Get next item from queue"""
result = cls.execute(
select(cls)
.join(UniversalDownloadTable)
.where(
and_(
UniversalDownloadTable.status == 'pending',
cls.started_at.is_(None)
)
)
.order_by(cls.priority.desc(), cls.position)
.limit(1)
)
res = next(result).scalar()
return res
@classmethod
def remove_from_queue(cls, download_id: int):
"""Remove item from queue"""
return cls.execute(
delete(cls).where(cls.download_id == download_id),
commit=True
)
@classmethod
def get_queue_length(cls):
"""Get current queue length"""
result = cls.execute(
select(func.count(cls.id))
.join(UniversalDownloadTable)
.where(UniversalDownloadTable.status == 'pending')
)
return next(result).scalar() or 0
# Create default universal download sources
def create_default_universal_sources():
"""Create default universal download sources if they don't exist"""
default_sources = [
{
'service': 'spotify',
'display_name': 'Spotify',
'enabled': True,
'priority': 1,
'supported_types': ['track', 'album', 'playlist', 'artist'],
'features': ['metadata', 'download', 'playlist'],
'config': {
'quality_preference': ['lossless', 'high', 'medium', 'low'],
'formats': ['flac', 'mp3', 'aac']
}
},
{
'service': 'tidal',
'display_name': 'Tidal',
'enabled': True,
'priority': 2,
'supported_types': ['track', 'album', 'playlist', 'artist'],
'features': ['metadata', 'download', 'playlist'],
'config': {
'quality_preference': ['lossless', 'high', 'medium', 'low'],
'formats': ['flac', 'mp3', 'aac']
}
},
{
'service': 'apple_music',
'display_name': 'Apple Music',
'enabled': True,
'priority': 3,
'supported_types': ['track', 'album', 'playlist', 'artist'],
'features': ['metadata', 'download', 'playlist'],
'config': {
'quality_preference': ['lossless', 'high', 'medium', 'low'],
'formats': ['flac', 'mp3', 'aac']
}
},
{
'service': 'youtube_music',
'display_name': 'YouTube Music',
'enabled': True,
'priority': 4,
'supported_types': ['video', 'playlist', 'channel'],
'features': ['metadata', 'download'],
'config': {
'quality_preference': ['high', 'medium', 'low'],
'formats': ['mp3', 'webm']
}
},
{
'service': 'youtube',
'display_name': 'YouTube',
'enabled': True,
'priority': 5,
'supported_types': ['video', 'playlist', 'channel'],
'features': ['metadata', 'download'],
'config': {
'quality_preference': ['high', 'medium', 'low'],
'formats': ['mp4', 'webm', 'mp3']
}
},
{
'service': 'soundcloud',
'display_name': 'SoundCloud',
'enabled': True,
'priority': 6,
'supported_types': ['track', 'playlist', 'artist'],
'features': ['metadata', 'download'],
'config': {
'quality_preference': ['high', 'medium', 'low'],
'formats': ['mp3']
}
},
{
'service': 'deezer',
'display_name': 'Deezer',
'enabled': False, # Disabled by default
'priority': 7,
'supported_types': ['track', 'album', 'playlist', 'artist'],
'features': ['metadata', 'download', 'playlist'],
'config': {
'quality_preference': ['lossless', 'high', 'medium', 'low'],
'formats': ['flac', 'mp3']
}
},
{
'service': 'bandcamp',
'display_name': 'Bandcamp',
'enabled': False, # Disabled by default
'priority': 8,
'supported_types': ['track', 'album'],
'features': ['metadata', 'download'],
'config': {
'quality_preference': ['lossless', 'high', 'medium', 'low'],
'formats': ['flac', 'mp3', 'aac']
}
}
]
current_time = datetime.now().timestamp()
for source_data in default_sources:
source_data['created_at'] = current_time
source_data['updated_at'] = current_time
existing = UniversalDownloadSourceTable.get_by_service(source_data['service'])
if not existing:
UniversalDownloadSourceTable.insert_one(source_data)
# Add execute method for new universal tables
for table_class in [UniversalDownloadTable, UniversalDownloadSourceTable, UniversalDownloadQueueTable]:
if not hasattr(table_class, 'execute'):
@classmethod
def execute_method(cls, query, commit=False):
engine = DbEngine()
with engine.session() as session:
result = session.execute(query)
if commit:
session.commit()
return result
table_class.execute = execute_method
table_class.insert_one = lambda data: table_class.execute(insert(table_class).values(data), commit=True)