Files
SpotifyRecAlg/swingmusic/db/spotify.py
T
Tomas Dvorak 6e8fedf534 first commit
2026-04-13 17:46:58 +02:00

1043 lines
36 KiB
Python

"""
Database models for Spotify downloader functionality
"""
from datetime import datetime
from sqlalchemy import (
JSON,
Boolean,
Float,
ForeignKey,
Integer,
String,
Text,
and_,
delete,
func,
insert,
or_,
select,
update,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from swingmusic.db import Base
from swingmusic.db.engine import DbEngine
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[str | None] = mapped_column(String(500), nullable=True)
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
image_url: Mapped[str | None] = mapped_column(Text, nullable=True)
release_date: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Download settings
quality: Mapped[str] = mapped_column(String(20), nullable=False, default="flac")
output_dir: Mapped[str] = mapped_column(String(1000), nullable=False, default="")
source: Mapped[str] = mapped_column(String(20), nullable=False, default="tidal")
# Download status
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
progress: Mapped[int] = mapped_column(Integer, default=0)
file_path: Mapped[str | None] = mapped_column(
String(1000), nullable=True, default=None
)
file_size: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
# Error handling
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
retry_count: Mapped[int] = mapped_column(Integer, default=0)
max_retries: Mapped[int] = mapped_column(Integer, default=3)
# Metadata
catalog_metadata: Mapped[dict | None] = mapped_column(
JSON, nullable=True, default=None
)
# Timestamps
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
started_at: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
completed_at: Mapped[float | None] = mapped_column(
Float, nullable=True, default=None
)
updated_at: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
# User association
user_id: Mapped[int | None] = mapped_column(
ForeignKey("user.id"), nullable=True, default=None
)
@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 list(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 list(next(result).scalars())
@classmethod
def get_download_history(
cls, user_id: int | None = 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 list(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[dict | None] = mapped_column(JSON, nullable=True, default=None)
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
updated_at: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
@classmethod
def get_active_sources(cls):
"""Get all active download sources ordered by priority"""
result = cls.execute(select(cls).where(cls.is_active).order_by(cls.priority))
return list(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, default=0)
added_at: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
started_at: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
# 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[str | None] = mapped_column(String(500), nullable=True)
album: Mapped[str | None] = mapped_column(String(500), nullable=True)
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
popularity: Mapped[int | None] = mapped_column(Integer, nullable=True)
preview_url: Mapped[str | None] = mapped_column(Text, nullable=True)
image_url: Mapped[str | None] = mapped_column(Text, nullable=True)
release_date: Mapped[str | None] = mapped_column(String(50), nullable=True)
explicit: Mapped[bool] = mapped_column(Boolean, default=False)
data: Mapped[dict | None] = mapped_column(
JSON, nullable=True, default=None
) # Full metadata JSON
cached_at: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
expires_at: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
@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 list(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 list(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[list | None] = mapped_column(
JSON, nullable=True, default=None
)
excluded_genres: Mapped[list | None] = mapped_column(
JSON, nullable=True, default=None
)
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[list | None] = mapped_column(
JSON, nullable=True, default=None
)
cache_ttl_preference: Mapped[int] = mapped_column(Integer, default=3600) # 1 hour
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
updated_at: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
@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,
}
cls.insert_one(default_prefs)
result = cls.execute(select(cls).where(cls.user_id == user_id))
return next(result).scalar()
@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[str | None] = mapped_column(String(500), nullable=True)
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
image_url: Mapped[str | None] = mapped_column(Text, nullable=True)
release_date: Mapped[str | None] = 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, default="")
# Download status
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
progress: Mapped[int] = mapped_column(Integer, default=0)
file_path: Mapped[str | None] = mapped_column(
String(1000), nullable=True, default=None
)
file_size: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
# Error handling
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
retry_count: Mapped[int] = mapped_column(Integer, default=0)
max_retries: Mapped[int] = mapped_column(Integer, default=3)
# Metadata
catalog_metadata: Mapped[dict | None] = mapped_column(
JSON, nullable=True, default=None
)
# Timestamps
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
started_at: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
completed_at: Mapped[float | None] = mapped_column(
Float, nullable=True, default=None
)
updated_at: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
# User association
user_id: Mapped[int | None] = mapped_column(
ForeignKey("user.id"), nullable=True, default=None
)
@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 list(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 list(next(result).scalars())
@classmethod
def get_download_history(
cls, user_id: int | None = 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 list(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 list(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[list | None] = mapped_column(
JSON, nullable=True, default=None
) # track, album, playlist, artist
features: Mapped[list | None] = mapped_column(
JSON, nullable=True, default=None
) # metadata, download, playlist
config: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=None)
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
updated_at: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
@classmethod
def get_enabled_sources(cls):
"""Get all enabled download sources ordered by priority"""
result = cls.execute(select(cls).where(cls.enabled).order_by(cls.priority))
return list(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, default=0)
added_at: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
started_at: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
# 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
)