mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-03 20:13:03 +00:00
1043 lines
36 KiB
Python
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
|
|
)
|