Implemented queue parsing for deezspot 2.0
This commit is contained in:
@@ -2,4 +2,4 @@ waitress==3.0.2
|
|||||||
celery==5.5.3
|
celery==5.5.3
|
||||||
Flask==3.1.1
|
Flask==3.1.1
|
||||||
flask_cors==6.0.0
|
flask_cors==6.0.0
|
||||||
deezspot-spotizerr==1.10.0
|
deezspot-spotizerr==2.0.3
|
||||||
@@ -116,10 +116,16 @@ def delete_task(task_id):
|
|||||||
def list_tasks():
|
def list_tasks():
|
||||||
"""
|
"""
|
||||||
Retrieve a list of all tasks in the system.
|
Retrieve a list of all tasks in the system.
|
||||||
Returns a detailed list of task objects including status and metadata.
|
Returns a detailed list of task objects including status and metadata,
|
||||||
|
formatted according to the callback documentation.
|
||||||
|
By default, it returns active tasks. Use ?include_finished=true to include completed tasks.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
tasks = get_all_tasks() # This already gets summary data
|
# Check for 'include_finished' query parameter
|
||||||
|
include_finished_str = request.args.get("include_finished", "false")
|
||||||
|
include_finished = include_finished_str.lower() in ["true", "1", "yes"]
|
||||||
|
|
||||||
|
tasks = get_all_tasks(include_finished=include_finished)
|
||||||
detailed_tasks = []
|
detailed_tasks = []
|
||||||
for task_summary in tasks:
|
for task_summary in tasks:
|
||||||
task_id = task_summary.get("task_id")
|
task_id = task_summary.get("task_id")
|
||||||
@@ -130,33 +136,29 @@ def list_tasks():
|
|||||||
last_status = get_last_task_status(task_id)
|
last_status = get_last_task_status(task_id)
|
||||||
|
|
||||||
if task_info and last_status:
|
if task_info and last_status:
|
||||||
task_details = {
|
# Start with the last status object as the base.
|
||||||
"task_id": task_id,
|
# This object should conform to one of the callback types.
|
||||||
"type": task_info.get(
|
task_details = last_status.copy()
|
||||||
"type", task_summary.get("type", "unknown")
|
|
||||||
),
|
# Add essential metadata to the task details
|
||||||
"name": task_info.get(
|
task_details["task_id"] = task_id
|
||||||
"name", task_summary.get("name", "Unknown")
|
task_details["original_request"] = task_info.get(
|
||||||
),
|
"original_request", {}
|
||||||
"artist": task_info.get(
|
)
|
||||||
"artist", task_summary.get("artist", "")
|
task_details["created_at"] = task_info.get("created_at", 0)
|
||||||
),
|
|
||||||
"download_type": task_info.get(
|
# Ensure core properties from task_info are present if not in status
|
||||||
"download_type",
|
if "type" not in task_details:
|
||||||
task_summary.get("download_type", "unknown"),
|
task_details["type"] = task_info.get("type", "unknown")
|
||||||
),
|
if "name" not in task_details:
|
||||||
"status": last_status.get(
|
task_details["name"] = task_info.get("name", "Unknown")
|
||||||
"status", "unknown"
|
if "artist" not in task_details:
|
||||||
), # Keep summary status for quick access
|
task_details["artist"] = task_info.get("artist", "")
|
||||||
"last_status_obj": last_status, # Full last status object
|
if "download_type" not in task_details:
|
||||||
"original_request": task_info.get("original_request", {}),
|
task_details["download_type"] = task_info.get(
|
||||||
"created_at": task_info.get("created_at", 0),
|
"download_type", "unknown"
|
||||||
"timestamp": last_status.get(
|
)
|
||||||
"timestamp", task_info.get("created_at", 0)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
if last_status.get("summary"):
|
|
||||||
task_details["summary"] = last_status["summary"]
|
|
||||||
detailed_tasks.append(task_details)
|
detailed_tasks.append(task_details)
|
||||||
elif (
|
elif (
|
||||||
task_info
|
task_info
|
||||||
@@ -169,7 +171,6 @@ def list_tasks():
|
|||||||
"artist": task_info.get("artist", ""),
|
"artist": task_info.get("artist", ""),
|
||||||
"download_type": task_info.get("download_type", "unknown"),
|
"download_type": task_info.get("download_type", "unknown"),
|
||||||
"status": "unknown",
|
"status": "unknown",
|
||||||
"last_status_obj": None,
|
|
||||||
"original_request": task_info.get("original_request", {}),
|
"original_request": task_info.get("original_request", {}),
|
||||||
"created_at": task_info.get("created_at", 0),
|
"created_at": task_info.get("created_at", 0),
|
||||||
"timestamp": task_info.get("created_at", 0),
|
"timestamp": task_info.get("created_at", 0),
|
||||||
|
|||||||
@@ -513,37 +513,59 @@ def retry_task(task_id):
|
|||||||
return {"status": "error", "error": str(e)}
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def get_all_tasks():
|
def get_all_tasks(include_finished=False):
|
||||||
"""Get all active task IDs"""
|
"""Get all active task IDs, with an option to include finished tasks."""
|
||||||
try:
|
try:
|
||||||
# Get all keys matching the task info pattern
|
task_keys = redis_client.scan_iter("task:*:info")
|
||||||
task_keys = redis_client.keys("task:*:info")
|
|
||||||
|
|
||||||
# Extract task IDs from the keys
|
|
||||||
task_ids = [key.decode("utf-8").split(":")[1] for key in task_keys]
|
|
||||||
|
|
||||||
# Get info for each task
|
|
||||||
tasks = []
|
tasks = []
|
||||||
for task_id in task_ids:
|
|
||||||
task_info = get_task_info(task_id)
|
|
||||||
last_status = get_last_task_status(task_id)
|
|
||||||
|
|
||||||
if task_info and last_status:
|
TERMINAL_STATES = {
|
||||||
tasks.append(
|
ProgressState.COMPLETE,
|
||||||
{
|
ProgressState.DONE,
|
||||||
|
ProgressState.CANCELLED,
|
||||||
|
ProgressState.ERROR,
|
||||||
|
ProgressState.ERROR_AUTO_CLEANED,
|
||||||
|
ProgressState.ERROR_RETRIED,
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in task_keys:
|
||||||
|
task_id = key.decode("utf-8").split(":")[1]
|
||||||
|
last_status = get_last_task_status(task_id)
|
||||||
|
current_status = None
|
||||||
|
|
||||||
|
if last_status:
|
||||||
|
# Accommodate for status being nested inside 'status_info' or at the top level
|
||||||
|
if "status" in last_status:
|
||||||
|
current_status = last_status.get("status")
|
||||||
|
elif isinstance(last_status.get("status_info"), dict):
|
||||||
|
current_status = last_status.get("status_info", {}).get("status")
|
||||||
|
|
||||||
|
is_terminal = current_status in TERMINAL_STATES
|
||||||
|
if not include_finished and is_terminal:
|
||||||
|
continue # Skip terminal tasks if not requested
|
||||||
|
|
||||||
|
task_info = get_task_info(task_id)
|
||||||
|
if task_info:
|
||||||
|
task_summary = {
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"type": task_info.get("type", "unknown"),
|
"type": task_info.get("type", "unknown"),
|
||||||
"name": task_info.get("name", "Unknown"),
|
"name": task_info.get("name", "Unknown"),
|
||||||
"artist": task_info.get("artist", ""),
|
"artist": task_info.get("artist", ""),
|
||||||
"download_type": task_info.get("download_type", "unknown"),
|
"download_type": task_info.get("download_type", "unknown"),
|
||||||
"status": last_status.get("status", "unknown"),
|
"created_at": task_info.get("created_at", 0),
|
||||||
"timestamp": last_status.get("timestamp", 0),
|
|
||||||
}
|
}
|
||||||
)
|
if last_status:
|
||||||
|
task_summary["status"] = current_status if current_status else "unknown"
|
||||||
|
task_summary["summary"] = last_status.get("summary")
|
||||||
|
else:
|
||||||
|
task_summary["status"] = "unknown"
|
||||||
|
task_summary["summary"] = None
|
||||||
|
|
||||||
|
tasks.append(task_summary)
|
||||||
|
|
||||||
return tasks
|
return tasks
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting all tasks: {e}")
|
logger.error(f"Error getting all tasks: {e}", exc_info=True)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -564,7 +586,7 @@ class ProgressTrackingTask(Task):
|
|||||||
task_id = self.request.id
|
task_id = self.request.id
|
||||||
|
|
||||||
# Ensure ./logs/tasks directory exists
|
# Ensure ./logs/tasks directory exists
|
||||||
logs_tasks_dir = Path("./logs/tasks") # Using relative path as per your update
|
logs_tasks_dir = Path("./logs/tasks")
|
||||||
try:
|
try:
|
||||||
logs_tasks_dir.mkdir(parents=True, exist_ok=True)
|
logs_tasks_dir.mkdir(parents=True, exist_ok=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -578,235 +600,118 @@ class ProgressTrackingTask(Task):
|
|||||||
# Log progress_data to the task-specific file
|
# Log progress_data to the task-specific file
|
||||||
try:
|
try:
|
||||||
with open(log_file_path, "a") as log_file:
|
with open(log_file_path, "a") as log_file:
|
||||||
# Add a timestamp to the log entry if not present, for consistency in the file
|
|
||||||
log_entry = progress_data.copy()
|
log_entry = progress_data.copy()
|
||||||
if "timestamp" not in log_entry:
|
if "timestamp" not in log_entry:
|
||||||
log_entry["timestamp"] = time.time()
|
log_entry["timestamp"] = time.time()
|
||||||
print(json.dumps(log_entry), file=log_file) # Use print to file
|
print(json.dumps(log_entry), file=log_file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Task {task_id}: Could not write to task log file {log_file_path}: {e}"
|
f"Task {task_id}: Could not write to task log file {log_file_path}: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add timestamp if not present
|
|
||||||
if "timestamp" not in progress_data:
|
if "timestamp" not in progress_data:
|
||||||
progress_data["timestamp"] = time.time()
|
progress_data["timestamp"] = time.time()
|
||||||
|
|
||||||
# Get status type
|
|
||||||
status = progress_data.get("status", "unknown")
|
status = progress_data.get("status", "unknown")
|
||||||
|
|
||||||
# Get task info for context
|
|
||||||
task_info = get_task_info(task_id)
|
task_info = get_task_info(task_id)
|
||||||
|
|
||||||
# Log raw progress data at debug level
|
|
||||||
if logger.isEnabledFor(logging.DEBUG):
|
if logger.isEnabledFor(logging.DEBUG):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Task {task_id}: Raw progress data: {json.dumps(progress_data)}"
|
f"Task {task_id}: Raw progress data: {json.dumps(progress_data)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process based on status type using a more streamlined approach
|
|
||||||
if status == "initializing":
|
if status == "initializing":
|
||||||
# --- INITIALIZING: Start of a download operation ---
|
|
||||||
self._handle_initializing(task_id, progress_data, task_info)
|
self._handle_initializing(task_id, progress_data, task_info)
|
||||||
|
|
||||||
elif status == "downloading":
|
elif status == "downloading":
|
||||||
# --- DOWNLOADING: Track download started ---
|
|
||||||
self._handle_downloading(task_id, progress_data, task_info)
|
self._handle_downloading(task_id, progress_data, task_info)
|
||||||
|
|
||||||
elif status == "progress":
|
elif status == "progress":
|
||||||
# --- PROGRESS: Album/playlist track progress ---
|
|
||||||
self._handle_progress(task_id, progress_data, task_info)
|
self._handle_progress(task_id, progress_data, task_info)
|
||||||
|
elif status in ["real_time", "track_progress"]:
|
||||||
elif status == "real_time" or status == "track_progress":
|
|
||||||
# --- REAL_TIME/TRACK_PROGRESS: Track download real-time progress ---
|
|
||||||
self._handle_real_time(task_id, progress_data)
|
self._handle_real_time(task_id, progress_data)
|
||||||
|
|
||||||
elif status == "skipped":
|
elif status == "skipped":
|
||||||
# --- SKIPPED: Track was skipped ---
|
|
||||||
self._handle_skipped(task_id, progress_data, task_info)
|
self._handle_skipped(task_id, progress_data, task_info)
|
||||||
|
|
||||||
elif status == "retrying":
|
elif status == "retrying":
|
||||||
# --- RETRYING: Download failed and being retried ---
|
|
||||||
self._handle_retrying(task_id, progress_data, task_info)
|
self._handle_retrying(task_id, progress_data, task_info)
|
||||||
|
|
||||||
elif status == "error":
|
elif status == "error":
|
||||||
# --- ERROR: Error occurred during download ---
|
|
||||||
self._handle_error(task_id, progress_data, task_info)
|
self._handle_error(task_id, progress_data, task_info)
|
||||||
|
|
||||||
elif status == "done":
|
elif status == "done":
|
||||||
# --- DONE: Download operation completed ---
|
|
||||||
self._handle_done(task_id, progress_data, task_info)
|
self._handle_done(task_id, progress_data, task_info)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# --- UNKNOWN: Unrecognized status ---
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Task {task_id} {status}: {progress_data.get('message', 'No details')}"
|
f"Task {task_id} {status}: {progress_data.get('message', 'No details')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Embed the raw callback data into the status object before storing
|
|
||||||
progress_data["raw_callback"] = raw_callback_data
|
progress_data["raw_callback"] = raw_callback_data
|
||||||
|
|
||||||
# Store the processed status update
|
|
||||||
store_task_status(task_id, progress_data)
|
store_task_status(task_id, progress_data)
|
||||||
|
|
||||||
def _handle_initializing(self, task_id, data, task_info):
|
def _handle_initializing(self, task_id, data, task_info):
|
||||||
"""Handle initializing status from deezspot"""
|
"""Handle initializing status from deezspot"""
|
||||||
# Extract relevant fields
|
logger.info(f"Task {task_id} initializing...")
|
||||||
content_type = data.get("type", "").upper()
|
# Initializing object is now very basic, mainly for acknowledging the start.
|
||||||
name = data.get("name", "")
|
# More detailed info comes with 'progress' or 'downloading' states.
|
||||||
album_name = data.get("album", "")
|
data["status"] = ProgressState.INITIALIZING
|
||||||
artist = data.get("artist", "")
|
|
||||||
total_tracks = data.get("total_tracks", 0)
|
|
||||||
|
|
||||||
# Use album name as name if name is empty
|
|
||||||
if not name and album_name:
|
|
||||||
data["name"] = album_name
|
|
||||||
|
|
||||||
# Log initialization with appropriate detail level
|
|
||||||
if album_name and artist:
|
|
||||||
logger.info(
|
|
||||||
f"Task {task_id} initializing: {content_type} '{album_name}' by {artist} with {total_tracks} tracks"
|
|
||||||
)
|
|
||||||
elif album_name:
|
|
||||||
logger.info(
|
|
||||||
f"Task {task_id} initializing: {content_type} '{album_name}' with {total_tracks} tracks"
|
|
||||||
)
|
|
||||||
elif name:
|
|
||||||
logger.info(
|
|
||||||
f"Task {task_id} initializing: {content_type} '{name}' with {total_tracks} tracks"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
f"Task {task_id} initializing: {content_type} with {total_tracks} tracks"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update task info with total tracks count
|
|
||||||
if total_tracks > 0:
|
|
||||||
task_info["total_tracks"] = total_tracks
|
|
||||||
task_info["completed_tracks"] = task_info.get("completed_tracks", 0)
|
|
||||||
task_info["skipped_tracks"] = task_info.get("skipped_tracks", 0)
|
|
||||||
store_task_info(task_id, task_info)
|
|
||||||
|
|
||||||
# Update status in data
|
|
||||||
# data["status"] = ProgressState.INITIALIZING
|
|
||||||
|
|
||||||
def _handle_downloading(self, task_id, data, task_info):
|
def _handle_downloading(self, task_id, data, task_info):
|
||||||
"""Handle downloading status from deezspot"""
|
"""Handle downloading status from deezspot"""
|
||||||
# Extract relevant fields
|
track_obj = data.get("track", {})
|
||||||
track_name = data.get("song", "Unknown")
|
track_name = track_obj.get("title", "Unknown")
|
||||||
artist = data.get("artist", "")
|
|
||||||
album = data.get("album", "")
|
|
||||||
download_type = data.get("type", "")
|
|
||||||
|
|
||||||
# Get parent task context
|
artists = track_obj.get("artists", [])
|
||||||
parent_type = task_info.get("type", "").lower()
|
artist_name = artists[0].get("name", "") if artists else ""
|
||||||
|
|
||||||
# If this is a track within an album/playlist, update progress
|
album_obj = track_obj.get("album", {})
|
||||||
if parent_type in ["album", "playlist"] and download_type == "track":
|
album_name = album_obj.get("title", "")
|
||||||
total_tracks = task_info.get("total_tracks", 0)
|
|
||||||
current_track = task_info.get("current_track_num", 0) + 1
|
|
||||||
|
|
||||||
# Update task info
|
logger.info(f"Task {task_id}: Starting download for track '{track_name}' by {artist_name}")
|
||||||
task_info["current_track_num"] = current_track
|
|
||||||
task_info["current_track"] = track_name
|
|
||||||
task_info["current_artist"] = artist
|
|
||||||
store_task_info(task_id, task_info)
|
|
||||||
|
|
||||||
# Only calculate progress if we have total tracks
|
data["status"] = ProgressState.DOWNLOADING
|
||||||
if total_tracks > 0:
|
data["song"] = track_name
|
||||||
overall_progress = min(int((current_track / total_tracks) * 100), 100)
|
data["artist"] = artist_name
|
||||||
data["overall_progress"] = overall_progress
|
data["album"] = album_name
|
||||||
data["parsed_current_track"] = current_track
|
|
||||||
data["parsed_total_tracks"] = total_tracks
|
|
||||||
|
|
||||||
# Create a progress update for the album/playlist
|
|
||||||
progress_update = {
|
|
||||||
"status": ProgressState.DOWNLOADING,
|
|
||||||
"type": parent_type,
|
|
||||||
"track": track_name,
|
|
||||||
"current_track": f"{current_track}/{total_tracks}",
|
|
||||||
"album": album,
|
|
||||||
"artist": artist,
|
|
||||||
"timestamp": data["timestamp"],
|
|
||||||
"parent_task": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Store separate progress update
|
|
||||||
store_task_status(task_id, progress_update)
|
|
||||||
|
|
||||||
# Log with appropriate detail level
|
|
||||||
if artist and album:
|
|
||||||
logger.info(
|
|
||||||
f"Task {task_id} downloading: '{track_name}' by {artist} from {album}"
|
|
||||||
)
|
|
||||||
elif artist:
|
|
||||||
logger.info(f"Task {task_id} downloading: '{track_name}' by {artist}")
|
|
||||||
else:
|
|
||||||
logger.info(f"Task {task_id} downloading: '{track_name}'")
|
|
||||||
|
|
||||||
# Update status
|
|
||||||
# data["status"] = ProgressState.DOWNLOADING
|
|
||||||
|
|
||||||
def _handle_progress(self, task_id, data, task_info):
|
def _handle_progress(self, task_id, data, task_info):
|
||||||
"""Handle progress status from deezspot"""
|
"""Handle progress status for albums/playlists from deezspot"""
|
||||||
# Extract track info
|
item = data.get("playlist") or data.get("album", {})
|
||||||
track_name = data.get("track", data.get("song", "Unknown track"))
|
track = data.get("track", {})
|
||||||
current_track_raw = data.get("current_track", "0")
|
|
||||||
album = data.get("album", "")
|
|
||||||
artist = data.get("artist", "")
|
|
||||||
|
|
||||||
# Process artist if it's a list
|
item_name = item.get("title", "Unknown Item")
|
||||||
if isinstance(artist, list) and len(artist) > 0:
|
total_tracks = item.get("total_tracks", 0)
|
||||||
data["artist_name"] = artist[0]
|
|
||||||
elif isinstance(artist, str):
|
|
||||||
data["artist_name"] = artist
|
|
||||||
|
|
||||||
# Parse track numbers from "current/total" format
|
track_name = track.get("title", "Unknown Track")
|
||||||
if isinstance(current_track_raw, str) and "/" in current_track_raw:
|
artists = track.get("artists", [])
|
||||||
try:
|
artist_name = artists[0].get("name", "") if artists else ""
|
||||||
parts = current_track_raw.split("/")
|
|
||||||
current_track = int(parts[0])
|
|
||||||
total_tracks = int(parts[1])
|
|
||||||
|
|
||||||
# Update with parsed values
|
# The 'progress' field in the callback is the track number being processed
|
||||||
data["parsed_current_track"] = current_track
|
current_track_num = data.get("progress", 0)
|
||||||
data["parsed_total_tracks"] = total_tracks
|
|
||||||
|
|
||||||
# Calculate percentage
|
if total_tracks > 0:
|
||||||
overall_progress = min(int((current_track / total_tracks) * 100), 100)
|
|
||||||
data["overall_progress"] = overall_progress
|
|
||||||
|
|
||||||
# Update task info
|
|
||||||
task_info["current_track_num"] = current_track
|
|
||||||
task_info["total_tracks"] = total_tracks
|
task_info["total_tracks"] = total_tracks
|
||||||
task_info["current_track"] = track_name
|
task_info["completed_tracks"] = current_track_num - 1
|
||||||
|
task_info["current_track_num"] = current_track_num
|
||||||
store_task_info(task_id, task_info)
|
store_task_info(task_id, task_info)
|
||||||
|
|
||||||
# Log progress with appropriate detail
|
overall_progress = min(int(((current_track_num -1) / total_tracks) * 100), 100)
|
||||||
artist_name = data.get("artist_name", artist)
|
data["overall_progress"] = overall_progress
|
||||||
if album and artist_name:
|
data["parsed_current_track"] = current_track_num
|
||||||
logger.info(
|
data["parsed_total_tracks"] = total_tracks
|
||||||
f"Task {task_id} progress: [{current_track}/{total_tracks}] {overall_progress}% - {track_name} by {artist_name} from {album}"
|
|
||||||
)
|
|
||||||
elif album:
|
|
||||||
logger.info(
|
|
||||||
f"Task {task_id} progress: [{current_track}/{total_tracks}] {overall_progress}% - {track_name} from {album}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
f"Task {task_id} progress: [{current_track}/{total_tracks}] {overall_progress}% - {track_name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except (ValueError, IndexError) as e:
|
logger.info(f"Task {task_id}: Progress on '{item_name}': Processing track {current_track_num}/{total_tracks} - '{track_name}'")
|
||||||
logger.error(f"Error parsing track numbers '{current_track_raw}': {e}")
|
|
||||||
|
|
||||||
# Ensure correct status
|
data["status"] = ProgressState.PROGRESS
|
||||||
# data["status"] = ProgressState.PROGRESS
|
data["song"] = track_name
|
||||||
|
data["artist"] = artist_name
|
||||||
|
data["current_track"] = f"{current_track_num}/{total_tracks}"
|
||||||
|
|
||||||
def _handle_real_time(self, task_id, data):
|
def _handle_real_time(self, task_id, data):
|
||||||
"""Handle real-time progress status from deezspot"""
|
"""Handle real-time progress status from deezspot"""
|
||||||
# Extract track info
|
track_obj = data.get("track", {})
|
||||||
title = data.get("title", data.get("song", "Unknown"))
|
track_name = track_obj.get("title", "Unknown Track")
|
||||||
|
percentage = data.get("percentage", 0)
|
||||||
|
|
||||||
|
logger.debug(f"Task {task_id}: Real-time progress for '{track_name}': {percentage}%")
|
||||||
|
|
||||||
|
data["status"] = ProgressState.TRACK_PROGRESS
|
||||||
|
data["song"] = track_name
|
||||||
artist = data.get("artist", "Unknown")
|
artist = data.get("artist", "Unknown")
|
||||||
|
|
||||||
# Handle percent formatting
|
# Handle percent formatting
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@tanstack/router-devtools": "^1.120.18",
|
"@tanstack/router-devtools": "^1.120.18",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
|
"lucide-react": "^0.515.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.57.0",
|
"react-hook-form": "^7.57.0",
|
||||||
@@ -44,5 +45,6 @@
|
|||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.30.1",
|
"typescript-eslint": "^8.30.1",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5"
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
|
||||||
}
|
}
|
||||||
|
|||||||
2551
spotizerr-ui/pnpm-lock.yaml
generated
2551
spotizerr-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,10 @@ import { QueueContext, type QueueItem, type QueueStatus } from "@/contexts/queue
|
|||||||
const isTerminalStatus = (status: QueueStatus) =>
|
const isTerminalStatus = (status: QueueStatus) =>
|
||||||
["completed", "error", "cancelled", "skipped", "done"].includes(status);
|
["completed", "error", "cancelled", "skipped", "done"].includes(status);
|
||||||
|
|
||||||
const statusStyles: Record<QueueStatus, { icon: React.ReactNode; color: string; bgColor: string; name: string }> = {
|
const statusStyles: Record<
|
||||||
|
QueueStatus,
|
||||||
|
{ icon: React.ReactNode; color: string; bgColor: string; name: string }
|
||||||
|
> = {
|
||||||
queued: {
|
queued: {
|
||||||
icon: <FaHourglassHalf />,
|
icon: <FaHourglassHalf />,
|
||||||
color: "text-gray-500",
|
color: "text-gray-500",
|
||||||
@@ -38,6 +41,12 @@ const statusStyles: Record<QueueStatus, { icon: React.ReactNode; color: string;
|
|||||||
bgColor: "bg-purple-100",
|
bgColor: "bg-purple-100",
|
||||||
name: "Processing",
|
name: "Processing",
|
||||||
},
|
},
|
||||||
|
retrying: {
|
||||||
|
icon: <FaSync className="animate-spin" />,
|
||||||
|
color: "text-orange-500",
|
||||||
|
bgColor: "bg-orange-100",
|
||||||
|
name: "Retrying",
|
||||||
|
},
|
||||||
completed: {
|
completed: {
|
||||||
icon: <FaCheckCircle />,
|
icon: <FaCheckCircle />,
|
||||||
color: "text-green-500",
|
color: "text-green-500",
|
||||||
@@ -79,16 +88,32 @@ const statusStyles: Record<QueueStatus, { icon: React.ReactNode; color: string;
|
|||||||
const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||||
const { removeItem, retryItem, cancelItem } = useContext(QueueContext) || {};
|
const { removeItem, retryItem, cancelItem } = useContext(QueueContext) || {};
|
||||||
const statusInfo = statusStyles[item.status] || statusStyles.queued;
|
const statusInfo = statusStyles[item.status] || statusStyles.queued;
|
||||||
|
|
||||||
const isTerminal = isTerminalStatus(item.status);
|
const isTerminal = isTerminalStatus(item.status);
|
||||||
const currentCount = isTerminal ? (item.summary?.successful?.length ?? item.totalTracks) : item.currentTrackNumber;
|
|
||||||
|
|
||||||
const progressText =
|
const getProgressText = () => {
|
||||||
item.type === "album" || item.type === "playlist"
|
const { status, type, progress, totalTracks, summary } = item;
|
||||||
? `${currentCount || 0}/${item.totalTracks || "?"}`
|
|
||||||
: item.progress
|
if (status === "downloading" || status === "processing") {
|
||||||
? `${item.progress.toFixed(0)}%`
|
if (type === "track") {
|
||||||
: "";
|
return progress !== undefined ? `${progress.toFixed(0)}%` : null;
|
||||||
|
}
|
||||||
|
// For albums/playlists, detailed progress is in the main body
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((status === "completed" || status === "done") && summary) {
|
||||||
|
if (type === "track") {
|
||||||
|
if (summary.total_successful > 0) return "Completed";
|
||||||
|
if (summary.total_failed > 0) return "Failed";
|
||||||
|
return "Finished";
|
||||||
|
}
|
||||||
|
return `${summary.total_successful}/${totalTracks} tracks`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressText = getProgressText();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`p-4 rounded-lg shadow-md mb-3 transition-all duration-300 ${statusInfo.bgColor}`}>
|
<div className={`p-4 rounded-lg shadow-md mb-3 transition-all duration-300 ${statusInfo.bgColor}`}>
|
||||||
@@ -96,20 +121,60 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
|||||||
<div className="flex items-center gap-4 min-w-0">
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
<div className={`text-2xl ${statusInfo.color}`}>{statusInfo.icon}</div>
|
<div className={`text-2xl ${statusInfo.color}`}>{statusInfo.icon}</div>
|
||||||
<div className="flex-grow min-w-0">
|
<div className="flex-grow min-w-0">
|
||||||
|
{item.type === "track" && (
|
||||||
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{item.type === "track" ? (
|
|
||||||
<FaMusic className="text-gray-500" />
|
<FaMusic className="text-gray-500" />
|
||||||
) : (
|
|
||||||
<FaCompactDisc className="text-gray-500" />
|
|
||||||
)}
|
|
||||||
<p className="font-bold text-gray-800 truncate" title={item.name}>
|
<p className="font-bold text-gray-800 truncate" title={item.name}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-gray-500 truncate" title={item.artist}>
|
<p className="text-sm text-gray-500 truncate" title={item.artist}>
|
||||||
{item.artist}
|
{item.artist}
|
||||||
</p>
|
</p>
|
||||||
|
{item.albumName && (
|
||||||
|
<p className="text-xs text-gray-500 truncate" title={item.albumName}>
|
||||||
|
{item.albumName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.type === "album" && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaCompactDisc className="text-gray-500" />
|
||||||
|
<p className="font-bold text-gray-800 truncate" title={item.name}>
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 truncate" title={item.artist}>
|
||||||
|
{item.artist}
|
||||||
|
</p>
|
||||||
|
{item.currentTrackTitle && (
|
||||||
|
<p className="text-xs text-gray-500 truncate" title={item.currentTrackTitle}>
|
||||||
|
{item.currentTrackNumber}/{item.totalTracks}: {item.currentTrackTitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.type === "playlist" && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaMusic className="text-gray-500" />
|
||||||
|
<p className="font-bold text-gray-800 truncate" title={item.name}>
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 truncate" title={item.playlistOwner}>
|
||||||
|
{item.playlistOwner}
|
||||||
|
</p>
|
||||||
|
{item.currentTrackTitle && (
|
||||||
|
<p className="text-xs text-gray-500 truncate" title={item.currentTrackTitle}>
|
||||||
|
{item.currentTrackNumber}/{item.totalTracks}: {item.currentTrackTitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -145,8 +210,22 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{item.error && <p className="text-xs text-red-600 mt-2">Error: {item.error}</p>}
|
{(item.status === "error" || item.status === "retrying") && item.error && (
|
||||||
{(item.status === "downloading" || item.status === "processing") && item.progress !== undefined && (
|
<p className="text-xs text-red-600 mt-2">Error: {item.error}</p>
|
||||||
|
)}
|
||||||
|
{isTerminal && item.summary && (item.summary.total_failed > 0 || item.summary.total_skipped > 0) && (
|
||||||
|
<div className="mt-2 text-xs">
|
||||||
|
{item.summary.total_failed > 0 && (
|
||||||
|
<p className="text-red-600">{item.summary.total_failed} track(s) failed.</p>
|
||||||
|
)}
|
||||||
|
{item.summary.total_skipped > 0 && (
|
||||||
|
<p className="text-yellow-600">{item.summary.total_skipped} track(s) skipped.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(item.status === "downloading" || item.status === "processing") &&
|
||||||
|
item.type === "track" &&
|
||||||
|
item.progress !== undefined && (
|
||||||
<div className="mt-2 h-1.5 w-full bg-gray-200 rounded-full">
|
<div className="mt-2 h-1.5 w-full bg-gray-200 rounded-full">
|
||||||
<div
|
<div
|
||||||
className={`h-1.5 rounded-full ${statusInfo.color.replace("text", "bg")}`}
|
className={`h-1.5 rounded-full ${statusInfo.color.replace("text", "bg")}`}
|
||||||
|
|||||||
@@ -1,66 +1,41 @@
|
|||||||
import { useState, useCallback, type ReactNode, useEffect, useRef } from "react";
|
import { useState, useCallback, type ReactNode, useEffect, useRef } from "react";
|
||||||
import apiClient from "../lib/api-client";
|
import apiClient from "../lib/api-client";
|
||||||
import { QueueContext, type QueueItem, type DownloadType, type QueueStatus } from "./queue-context";
|
import {
|
||||||
|
QueueContext,
|
||||||
|
type QueueItem,
|
||||||
|
type DownloadType,
|
||||||
|
type QueueStatus,
|
||||||
|
} from "./queue-context";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import type {
|
||||||
// --- Helper Types ---
|
CallbackObject,
|
||||||
// This represents the raw status object from the backend polling endpoint
|
SummaryObject,
|
||||||
interface TaskStatusDTO {
|
ProcessingCallbackObject,
|
||||||
status: QueueStatus;
|
TrackCallbackObject,
|
||||||
message?: string;
|
AlbumCallbackObject,
|
||||||
can_retry?: boolean;
|
PlaylistCallbackObject,
|
||||||
|
} from "@/types/callbacks";
|
||||||
// Progress indicators
|
|
||||||
progress?: number;
|
|
||||||
speed?: string;
|
|
||||||
size?: string;
|
|
||||||
eta?: string;
|
|
||||||
|
|
||||||
// Multi-track progress
|
|
||||||
current_track?: number;
|
|
||||||
total_tracks?: number;
|
|
||||||
summary?: {
|
|
||||||
successful_tracks: string[];
|
|
||||||
skipped_tracks: string[];
|
|
||||||
failed_tracks: number;
|
|
||||||
failed_track_details: { name: string; reason: string }[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task from prgs/list endpoint
|
|
||||||
interface TaskDTO {
|
|
||||||
task_id: string;
|
|
||||||
name?: string;
|
|
||||||
type?: string;
|
|
||||||
download_type?: string;
|
|
||||||
status?: string;
|
|
||||||
last_status_obj?: {
|
|
||||||
status?: string;
|
|
||||||
progress?: number;
|
|
||||||
speed?: string;
|
|
||||||
size?: string;
|
|
||||||
eta?: string;
|
|
||||||
current_track?: number;
|
|
||||||
total_tracks?: number;
|
|
||||||
error?: string;
|
|
||||||
can_retry?: boolean;
|
|
||||||
};
|
|
||||||
original_request?: {
|
|
||||||
url?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
summary?: {
|
|
||||||
successful_tracks: string[];
|
|
||||||
skipped_tracks: string[];
|
|
||||||
failed_tracks: number;
|
|
||||||
failed_track_details?: { name: string; reason: string }[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTerminalStatus = (status: QueueStatus) =>
|
const isTerminalStatus = (status: QueueStatus) =>
|
||||||
["completed", "error", "cancelled", "skipped", "done"].includes(status);
|
["completed", "error", "cancelled", "skipped", "done"].includes(status);
|
||||||
|
|
||||||
|
function isProcessingCallback(obj: CallbackObject): obj is ProcessingCallbackObject {
|
||||||
|
return obj && "status" in obj && obj.status === "processing";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrackCallback(obj: any): obj is TrackCallbackObject {
|
||||||
|
return obj && "track" in obj && "status_info" in obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlbumCallback(obj: any): obj is AlbumCallbackObject {
|
||||||
|
return obj && "album" in obj && "status_info" in obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlaylistCallback(obj: any): obj is PlaylistCallbackObject {
|
||||||
|
return obj && "playlist" in obj && "status_info" in obj;
|
||||||
|
}
|
||||||
|
|
||||||
export function QueueProvider({ children }: { children: ReactNode }) {
|
export function QueueProvider({ children }: { children: ReactNode }) {
|
||||||
const [items, setItems] = useState<QueueItem[]>(() => {
|
const [items, setItems] = useState<QueueItem[]>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -73,11 +48,26 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
|||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const pollingIntervals = useRef<Record<string, number>>({});
|
const pollingIntervals = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
// --- Persistence ---
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("queueItems", JSON.stringify(items));
|
localStorage.setItem("queueItems", JSON.stringify(items));
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
|
// Effect to resume polling for active tasks on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length > 0) {
|
||||||
|
items.forEach((item) => {
|
||||||
|
// If a task has an ID and is not in a finished state, restart polling.
|
||||||
|
if (item.taskId && !isTerminalStatus(item.status)) {
|
||||||
|
console.log(`Resuming polling for ${item.name} (Task ID: ${item.taskId})`);
|
||||||
|
startPolling(item.id, item.taskId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// This effect should only run once on mount to avoid re-triggering polling unnecessarily.
|
||||||
|
// We are disabling the dependency warning because we intentionally want to use the initial `items` state.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const stopPolling = useCallback((internalId: string) => {
|
const stopPolling = useCallback((internalId: string) => {
|
||||||
if (pollingIntervals.current[internalId]) {
|
if (pollingIntervals.current[internalId]) {
|
||||||
clearInterval(pollingIntervals.current[internalId]);
|
clearInterval(pollingIntervals.current[internalId]);
|
||||||
@@ -85,83 +75,98 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// --- Polling Logic ---
|
|
||||||
const startPolling = useCallback(
|
const startPolling = useCallback(
|
||||||
(internalId: string, taskId: string) => {
|
(internalId: string, taskId: string) => {
|
||||||
if (pollingIntervals.current[internalId]) return;
|
if (pollingIntervals.current[internalId]) return;
|
||||||
|
|
||||||
const intervalId = window.setInterval(async () => {
|
const intervalId = window.setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
// Use the prgs endpoint instead of download/status
|
|
||||||
interface PrgsResponse {
|
interface PrgsResponse {
|
||||||
status?: string;
|
status?: string;
|
||||||
summary?: TaskStatusDTO["summary"];
|
summary?: SummaryObject;
|
||||||
last_line?: {
|
last_line?: CallbackObject;
|
||||||
status?: string;
|
|
||||||
message?: string;
|
|
||||||
error?: string;
|
|
||||||
can_retry?: boolean;
|
|
||||||
progress?: number;
|
|
||||||
speed?: string;
|
|
||||||
size?: string;
|
|
||||||
eta?: string;
|
|
||||||
current_track?: number;
|
|
||||||
total_tracks?: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.get<PrgsResponse>(`/prgs/${taskId}`);
|
const response = await apiClient.get<PrgsResponse>(`/prgs/${taskId}`);
|
||||||
const lastStatus = response.data.last_line || {};
|
const { last_line, summary, status } = response.data;
|
||||||
const statusUpdate = {
|
|
||||||
status: response.data.status || lastStatus.status || "pending",
|
|
||||||
message: lastStatus.message || lastStatus.error,
|
|
||||||
can_retry: lastStatus.can_retry,
|
|
||||||
progress: lastStatus.progress,
|
|
||||||
speed: lastStatus.speed,
|
|
||||||
size: lastStatus.size,
|
|
||||||
eta: lastStatus.eta,
|
|
||||||
current_track: lastStatus.current_track,
|
|
||||||
total_tracks: lastStatus.total_tracks,
|
|
||||||
summary: response.data.summary,
|
|
||||||
};
|
|
||||||
|
|
||||||
setItems((prev) =>
|
setItems(prev =>
|
||||||
prev.map((item) => {
|
prev.map(item => {
|
||||||
if (item.id === internalId) {
|
if (item.id !== internalId) return item;
|
||||||
const updatedItem: QueueItem = {
|
|
||||||
...item,
|
const updatedItem: QueueItem = { ...item };
|
||||||
status: statusUpdate.status as QueueStatus,
|
|
||||||
progress: statusUpdate.progress,
|
if (status) {
|
||||||
speed: statusUpdate.speed,
|
updatedItem.status = status as QueueStatus;
|
||||||
size: statusUpdate.size,
|
|
||||||
eta: statusUpdate.eta,
|
|
||||||
error: statusUpdate.status === "error" ? statusUpdate.message : undefined,
|
|
||||||
canRetry: statusUpdate.can_retry,
|
|
||||||
currentTrackNumber: statusUpdate.current_track,
|
|
||||||
totalTracks: statusUpdate.total_tracks,
|
|
||||||
summary: statusUpdate.summary
|
|
||||||
? {
|
|
||||||
successful: statusUpdate.summary.successful_tracks,
|
|
||||||
skipped: statusUpdate.summary.skipped_tracks,
|
|
||||||
failed: statusUpdate.summary.failed_tracks,
|
|
||||||
failedTracks: statusUpdate.summary.failed_track_details || [],
|
|
||||||
}
|
}
|
||||||
: item.summary,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isTerminalStatus(statusUpdate.status as QueueStatus)) {
|
if (summary) {
|
||||||
|
updatedItem.summary = summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last_line) {
|
||||||
|
if (isProcessingCallback(last_line)) {
|
||||||
|
updatedItem.status = "processing";
|
||||||
|
} else if (isTrackCallback(last_line)) {
|
||||||
|
const { status_info, track, current_track, total_tracks, parent } = last_line;
|
||||||
|
|
||||||
|
updatedItem.currentTrackTitle = track.title;
|
||||||
|
if (current_track) updatedItem.currentTrackNumber = current_track;
|
||||||
|
if (total_tracks) updatedItem.totalTracks = total_tracks;
|
||||||
|
|
||||||
|
// A child track being "done" doesn't mean the whole download is done.
|
||||||
|
// The final "done" status comes from the parent (album/playlist) callback.
|
||||||
|
if (parent && status_info.status === "done") {
|
||||||
|
updatedItem.status = "downloading"; // Or keep current status if not 'error'
|
||||||
|
} else {
|
||||||
|
updatedItem.status = status_info.status as QueueStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status_info.status === "error" || status_info.status === "retrying") {
|
||||||
|
updatedItem.error = status_info.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For single tracks, the "done" status is final.
|
||||||
|
if (!parent && status_info.status === "done") {
|
||||||
|
if (status_info.summary) updatedItem.summary = status_info.summary;
|
||||||
|
}
|
||||||
|
} else if (isAlbumCallback(last_line)) {
|
||||||
|
const { status_info, album } = last_line;
|
||||||
|
updatedItem.status = status_info.status as QueueStatus;
|
||||||
|
updatedItem.name = album.title;
|
||||||
|
updatedItem.artist = album.artists.map(a => a.name).join(", ");
|
||||||
|
if (status_info.status === "done" && status_info.summary) {
|
||||||
|
updatedItem.summary = status_info.summary;
|
||||||
|
}
|
||||||
|
if (status_info.status === "error") {
|
||||||
|
updatedItem.error = status_info.error;
|
||||||
|
}
|
||||||
|
} else if (isPlaylistCallback(last_line)) {
|
||||||
|
const { status_info, playlist } = last_line;
|
||||||
|
updatedItem.status = status_info.status as QueueStatus;
|
||||||
|
updatedItem.name = playlist.title;
|
||||||
|
updatedItem.playlistOwner = playlist.owner.name;
|
||||||
|
if (status_info.status === "done" && status_info.summary) {
|
||||||
|
updatedItem.summary = status_info.summary;
|
||||||
|
}
|
||||||
|
if (status_info.status === "error") {
|
||||||
|
updatedItem.error = status_info.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTerminalStatus(updatedItem.status as QueueStatus)) {
|
||||||
stopPolling(internalId);
|
stopPolling(internalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedItem;
|
return updatedItem;
|
||||||
}
|
|
||||||
return item;
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Polling failed for task ${taskId}:`, error);
|
console.error(`Polling failed for task ${taskId}:`, error);
|
||||||
stopPolling(internalId);
|
stopPolling(internalId);
|
||||||
setItems((prev) =>
|
setItems(prev =>
|
||||||
prev.map((i) =>
|
prev.map(i =>
|
||||||
i.id === internalId
|
i.id === internalId
|
||||||
? {
|
? {
|
||||||
...i,
|
...i,
|
||||||
@@ -172,14 +177,23 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, 2000); // Poll every 2 seconds
|
}, 2000);
|
||||||
|
|
||||||
pollingIntervals.current[internalId] = intervalId;
|
pollingIntervals.current[internalId] = intervalId;
|
||||||
},
|
},
|
||||||
[stopPolling],
|
[stopPolling],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Core Action: Add Item ---
|
useEffect(() => {
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.taskId && !isTerminalStatus(item.status)) {
|
||||||
|
startPolling(item.id, item.taskId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// We only want to run this on mount, so we disable the exhaustive-deps warning.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const addItem = useCallback(
|
const addItem = useCallback(
|
||||||
async (item: { name: string; type: DownloadType; spotifyId: string; artist?: string }) => {
|
async (item: { name: string; type: DownloadType; spotifyId: string; artist?: string }) => {
|
||||||
const internalId = uuidv4();
|
const internalId = uuidv4();
|
||||||
@@ -193,7 +207,6 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let endpoint = "";
|
let endpoint = "";
|
||||||
|
|
||||||
if (item.type === "track") {
|
if (item.type === "track") {
|
||||||
endpoint = `/track/download/${item.spotifyId}`;
|
endpoint = `/track/download/${item.spotifyId}`;
|
||||||
} else if (item.type === "album") {
|
} else if (item.type === "album") {
|
||||||
@@ -230,36 +243,142 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
|||||||
[isVisible, startPolling],
|
[isVisible, startPolling],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const removeItem = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const item = items.find((i) => i.id === id);
|
||||||
|
if (item?.taskId) {
|
||||||
|
stopPolling(item.id);
|
||||||
|
}
|
||||||
|
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||||
|
},
|
||||||
|
[items, stopPolling],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelItem = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
const item = items.find((i) => i.id === id);
|
||||||
|
if (!item || !item.taskId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/prgs/cancel/${item.taskId}`);
|
||||||
|
stopPolling(id);
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((i) =>
|
||||||
|
i.id === id
|
||||||
|
? {
|
||||||
|
...i,
|
||||||
|
status: "cancelled",
|
||||||
|
}
|
||||||
|
: i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
toast.info(`Cancelled download: ${item.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to cancel task ${item.taskId}:`, error);
|
||||||
|
toast.error(`Failed to cancel download: ${item.name}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[items, stopPolling],
|
||||||
|
);
|
||||||
|
|
||||||
|
const retryItem = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const item = items.find((i) => i.id === id);
|
||||||
|
if (item && item.taskId) {
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((i) =>
|
||||||
|
i.id === id
|
||||||
|
? {
|
||||||
|
...i,
|
||||||
|
status: "pending",
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
: i,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
startPolling(id, item.taskId);
|
||||||
|
toast.info(`Retrying download: ${item.name}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[items, startPolling],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleVisibility = useCallback(() => {
|
||||||
|
setIsVisible((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearCompleted = useCallback(() => {
|
||||||
|
setItems((prev) => prev.filter((item) => !isTerminalStatus(item.status) || item.status === "error"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelAll = useCallback(async () => {
|
||||||
|
const activeItems = items.filter((item) => item.taskId && !isTerminalStatus(item.status));
|
||||||
|
if (activeItems.length === 0) {
|
||||||
|
toast.info("No active downloads to cancel.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const taskIds = activeItems.map((item) => item.taskId!);
|
||||||
|
await apiClient.post("/prgs/cancel/many", { task_ids: taskIds });
|
||||||
|
|
||||||
|
activeItems.forEach((item) => stopPolling(item.id));
|
||||||
|
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
taskIds.includes(item.taskId!)
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
status: "cancelled",
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
toast.info("Cancelled all active downloads.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to cancel all tasks:", error);
|
||||||
|
toast.error("Failed to cancel all downloads.");
|
||||||
|
}
|
||||||
|
}, [items, stopPolling]);
|
||||||
|
|
||||||
const clearAllPolls = useCallback(() => {
|
const clearAllPolls = useCallback(() => {
|
||||||
Object.values(pollingIntervals.current).forEach(clearInterval);
|
Object.values(pollingIntervals.current).forEach(clearInterval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// --- Load existing tasks on startup ---
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
interface PrgsListEntry {
|
||||||
|
task_id: string;
|
||||||
|
name?: string;
|
||||||
|
download_type?: string;
|
||||||
|
status?: string;
|
||||||
|
original_request?: { url?: string };
|
||||||
|
last_status_obj?: {
|
||||||
|
progress?: number;
|
||||||
|
current_track?: number;
|
||||||
|
total_tracks?: number;
|
||||||
|
error?: string;
|
||||||
|
can_retry?: boolean;
|
||||||
|
};
|
||||||
|
summary?: SummaryObject;
|
||||||
|
}
|
||||||
|
|
||||||
const syncActiveTasks = async () => {
|
const syncActiveTasks = async () => {
|
||||||
try {
|
try {
|
||||||
// Use the prgs/list endpoint instead of download/active
|
const response = await apiClient.get<PrgsListEntry[]>("/prgs/list");
|
||||||
const response = await apiClient.get<TaskDTO[]>("/prgs/list");
|
const activeTasks: QueueItem[] = response.data
|
||||||
|
|
||||||
// Map the prgs response to the expected QueueItem format
|
|
||||||
const activeTasks = response.data
|
|
||||||
.filter((task) => {
|
.filter((task) => {
|
||||||
// Only include non-terminal tasks
|
|
||||||
const status = task.status?.toLowerCase();
|
const status = task.status?.toLowerCase();
|
||||||
return status && !isTerminalStatus(status as QueueStatus);
|
return status && !isTerminalStatus(status as QueueStatus);
|
||||||
})
|
})
|
||||||
.map((task) => {
|
.map((task) => {
|
||||||
// Extract Spotify ID from URL if available
|
|
||||||
const url = task.original_request?.url || "";
|
const url = task.original_request?.url || "";
|
||||||
const spotifyId = url.includes("spotify.com") ? url.split("/").pop() || "" : "";
|
const spotifyId = url.includes("spotify.com") ? url.split("/").pop() || "" : "";
|
||||||
|
|
||||||
// Map download_type to UI type
|
|
||||||
let type: DownloadType = "track";
|
let type: DownloadType = "track";
|
||||||
if (task.download_type === "album") type = "album";
|
if (task.download_type === "album") type = "album";
|
||||||
if (task.download_type === "playlist") type = "playlist";
|
if (task.download_type === "playlist") type = "playlist";
|
||||||
if (task.download_type === "artist") type = "artist";
|
if (task.download_type === "artist") type = "artist";
|
||||||
|
|
||||||
return {
|
const queueItem: QueueItem = {
|
||||||
id: task.task_id,
|
id: task.task_id,
|
||||||
taskId: task.task_id,
|
taskId: task.task_id,
|
||||||
name: task.name || "Unknown",
|
name: task.name || "Unknown",
|
||||||
@@ -267,138 +386,36 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
|||||||
spotifyId,
|
spotifyId,
|
||||||
status: (task.status?.toLowerCase() || "pending") as QueueStatus,
|
status: (task.status?.toLowerCase() || "pending") as QueueStatus,
|
||||||
progress: task.last_status_obj?.progress,
|
progress: task.last_status_obj?.progress,
|
||||||
speed: task.last_status_obj?.speed,
|
|
||||||
size: task.last_status_obj?.size,
|
|
||||||
eta: task.last_status_obj?.eta,
|
|
||||||
currentTrackNumber: task.last_status_obj?.current_track,
|
currentTrackNumber: task.last_status_obj?.current_track,
|
||||||
totalTracks: task.last_status_obj?.total_tracks,
|
totalTracks: task.last_status_obj?.total_tracks,
|
||||||
error: task.last_status_obj?.error,
|
error: task.last_status_obj?.error,
|
||||||
canRetry: task.last_status_obj?.can_retry,
|
canRetry: task.last_status_obj?.can_retry,
|
||||||
summary: task.summary
|
summary: task.summary,
|
||||||
? {
|
|
||||||
successful: task.summary.successful_tracks,
|
|
||||||
skipped: task.summary.skipped_tracks,
|
|
||||||
failed: task.summary.failed_tracks,
|
|
||||||
failedTracks: task.summary.failed_track_details || [],
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
|
return queueItem;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Basic reconciliation
|
|
||||||
setItems((prevItems) => {
|
setItems((prevItems) => {
|
||||||
const newItems = [...prevItems];
|
const newItems = [...prevItems];
|
||||||
activeTasks.forEach((task) => {
|
activeTasks.forEach((task) => {
|
||||||
if (!newItems.some((item) => item.taskId === task.taskId)) {
|
const existingIndex = newItems.findIndex((item) => item.id === task.id);
|
||||||
|
if (existingIndex === -1) {
|
||||||
newItems.push(task);
|
newItems.push(task);
|
||||||
|
} else {
|
||||||
|
newItems[existingIndex] = { ...newItems[existingIndex], ...task };
|
||||||
}
|
}
|
||||||
|
startPolling(task.id, task.taskId!);
|
||||||
});
|
});
|
||||||
return newItems;
|
return newItems;
|
||||||
});
|
});
|
||||||
|
|
||||||
activeTasks.forEach((item) => {
|
|
||||||
if (item.id && item.taskId && !isTerminalStatus(item.status)) {
|
|
||||||
startPolling(item.id, item.taskId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to sync active tasks:", error);
|
console.error("Failed to sync active tasks:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
syncActiveTasks();
|
syncActiveTasks();
|
||||||
|
return () => clearAllPolls();
|
||||||
// restart polling for any non-terminal items from localStorage
|
}, [startPolling, clearAllPolls]);
|
||||||
items.forEach((item) => {
|
|
||||||
if (item.id && item.taskId && !isTerminalStatus(item.status)) {
|
|
||||||
startPolling(item.id, item.taskId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return clearAllPolls;
|
|
||||||
// This effect should only run once on mount to initialize the queue.
|
|
||||||
// We are intentionally omitting 'items' as a dependency to prevent re-runs.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [clearAllPolls, startPolling]);
|
|
||||||
|
|
||||||
// --- Other Actions ---
|
|
||||||
const removeItem = useCallback((id: string) => {
|
|
||||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const cancelItem = useCallback(
|
|
||||||
async (id: string) => {
|
|
||||||
const itemToCancel = items.find((i) => i.id === id);
|
|
||||||
if (itemToCancel && itemToCancel.taskId && !isTerminalStatus(itemToCancel.status)) {
|
|
||||||
stopPolling(id);
|
|
||||||
try {
|
|
||||||
await apiClient.post(`/prgs/cancel/${itemToCancel.taskId}`);
|
|
||||||
toast.success(`Cancelled download: ${itemToCancel.name}`);
|
|
||||||
setItems((prev) => prev.map((i) => (i.id === id ? { ...i, status: "cancelled" } : i)));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to cancel task ${itemToCancel.taskId}`, err);
|
|
||||||
toast.error(`Failed to cancel: ${itemToCancel.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[items, stopPolling],
|
|
||||||
);
|
|
||||||
|
|
||||||
const retryItem = useCallback(
|
|
||||||
async (id: string) => {
|
|
||||||
const itemToRetry = items.find((i) => i.id === id);
|
|
||||||
if (!itemToRetry || !itemToRetry.taskId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the prgs/retry endpoint
|
|
||||||
await apiClient.post(`/prgs/retry/${itemToRetry.taskId}`);
|
|
||||||
toast.info(`Retrying download: ${itemToRetry.name}`);
|
|
||||||
|
|
||||||
// Update the item status in the UI
|
|
||||||
setItems((prev) =>
|
|
||||||
prev.map((item) =>
|
|
||||||
item.id === id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
status: "initializing",
|
|
||||||
error: undefined,
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Start polling again
|
|
||||||
startPolling(id, itemToRetry.taskId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to retry download for ${itemToRetry.name}:`, error);
|
|
||||||
toast.error(`Failed to retry download: ${itemToRetry.name}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[items, startPolling],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cancelAll = useCallback(async () => {
|
|
||||||
toast.info("Cancelling all active downloads...");
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.taskId && !isTerminalStatus(item.status)) {
|
|
||||||
stopPolling(item.id);
|
|
||||||
try {
|
|
||||||
await apiClient.post(`/prgs/cancel/${item.taskId}`);
|
|
||||||
// Visually update the item to "cancelled" immediately
|
|
||||||
setItems((prev) => prev.map((i) => (i.id === item.id ? { ...i, status: "cancelled" } : i)));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to cancel task ${item.taskId}`, err);
|
|
||||||
toast.error(`Failed to cancel: ${item.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [items, stopPolling]);
|
|
||||||
|
|
||||||
const clearCompleted = useCallback(() => {
|
|
||||||
setItems((prev) => prev.filter((item) => !isTerminalStatus(item.status)));
|
|
||||||
toast.info("Cleared finished downloads.");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleVisibility = useCallback(() => setIsVisible((prev) => !prev), []);
|
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
items,
|
items,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
|
import type { SummaryObject } from "@/types/callbacks";
|
||||||
|
|
||||||
export type DownloadType = "track" | "album" | "artist" | "playlist";
|
export type DownloadType = "track" | "album" | "artist" | "playlist";
|
||||||
export type QueueStatus =
|
export type QueueStatus =
|
||||||
@@ -11,36 +12,33 @@ export type QueueStatus =
|
|||||||
| "skipped"
|
| "skipped"
|
||||||
| "cancelled"
|
| "cancelled"
|
||||||
| "done"
|
| "done"
|
||||||
| "queued";
|
| "queued"
|
||||||
|
| "retrying";
|
||||||
|
|
||||||
export interface QueueItem {
|
export interface QueueItem {
|
||||||
id: string; // Unique ID for the queue item (can be task_id from backend)
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
artist?: string;
|
|
||||||
type: DownloadType;
|
type: DownloadType;
|
||||||
spotifyId: string; // Original Spotify ID
|
spotifyId: string;
|
||||||
|
|
||||||
// --- Status and Progress ---
|
// Display Info
|
||||||
|
artist?: string;
|
||||||
|
albumName?: string;
|
||||||
|
playlistOwner?: string;
|
||||||
|
currentTrackTitle?: string;
|
||||||
|
|
||||||
|
// Status and Progress
|
||||||
status: QueueStatus;
|
status: QueueStatus;
|
||||||
taskId?: string; // The backend task ID for polling
|
taskId?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
canRetry?: boolean;
|
canRetry?: boolean;
|
||||||
|
progress?: number;
|
||||||
// --- Single Track Progress ---
|
|
||||||
progress?: number; // 0-100
|
|
||||||
speed?: string;
|
speed?: string;
|
||||||
size?: string;
|
size?: string;
|
||||||
eta?: string;
|
eta?: string;
|
||||||
|
|
||||||
// --- Multi-Track (Album/Playlist) Progress ---
|
|
||||||
currentTrackNumber?: number;
|
currentTrackNumber?: number;
|
||||||
totalTracks?: number;
|
totalTracks?: number;
|
||||||
summary?: {
|
summary?: SummaryObject;
|
||||||
successful: string[];
|
|
||||||
skipped: string[];
|
|
||||||
failed: number;
|
|
||||||
failedTracks: { name: string; reason: string }[];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueueContextType {
|
export interface QueueContextType {
|
||||||
|
|||||||
270
spotizerr-ui/src/types/callbacks.ts
Normal file
270
spotizerr-ui/src/types/callbacks.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
// Common Interfaces
|
||||||
|
export interface IDs {
|
||||||
|
spotify?: string;
|
||||||
|
deezer?: string;
|
||||||
|
isrc?: string;
|
||||||
|
upc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseDate {
|
||||||
|
year: number;
|
||||||
|
month?: number;
|
||||||
|
day?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Model
|
||||||
|
export interface UserObject {
|
||||||
|
name: string;
|
||||||
|
type: "user";
|
||||||
|
ids: IDs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track Module Models
|
||||||
|
|
||||||
|
export interface ArtistAlbumTrackObject {
|
||||||
|
type: "artistAlbumTrack";
|
||||||
|
name: string;
|
||||||
|
ids: IDs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistTrackObject {
|
||||||
|
type: "artistTrack";
|
||||||
|
name: string;
|
||||||
|
ids: IDs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlbumTrackObject {
|
||||||
|
type: "albumTrack";
|
||||||
|
album_type: "album" | "single" | "compilation";
|
||||||
|
title: string;
|
||||||
|
release_date: { [key: string]: any };
|
||||||
|
total_tracks: number;
|
||||||
|
genres: string[];
|
||||||
|
images: { [key: string]: any }[];
|
||||||
|
ids: IDs;
|
||||||
|
artists: ArtistAlbumTrackObject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistTrackObject {
|
||||||
|
type: "playlistTrack";
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
owner: UserObject;
|
||||||
|
ids: IDs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackObject {
|
||||||
|
type: "track";
|
||||||
|
title: string;
|
||||||
|
disc_number: number;
|
||||||
|
track_number: number;
|
||||||
|
duration_ms: number;
|
||||||
|
explicit: boolean;
|
||||||
|
genres: string[];
|
||||||
|
album: AlbumTrackObject;
|
||||||
|
artists: ArtistTrackObject[];
|
||||||
|
ids: IDs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist Module Models
|
||||||
|
|
||||||
|
export interface ArtistAlbumTrackPlaylistObject {
|
||||||
|
type: "artistAlbumTrackPlaylist";
|
||||||
|
name: string;
|
||||||
|
ids: IDs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlbumTrackPlaylistObject {
|
||||||
|
type: "albumTrackPlaylist";
|
||||||
|
album_type: string;
|
||||||
|
title: string;
|
||||||
|
release_date: { [key: string]: any };
|
||||||
|
total_tracks: number;
|
||||||
|
images: { [key: string]: any }[];
|
||||||
|
ids: IDs;
|
||||||
|
artists: ArtistAlbumTrackPlaylistObject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistTrackPlaylistObject {
|
||||||
|
type: "artistTrackPlaylist";
|
||||||
|
name: string;
|
||||||
|
ids: IDs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackPlaylistObject {
|
||||||
|
type: "trackPlaylist";
|
||||||
|
title: string;
|
||||||
|
position: number;
|
||||||
|
duration_ms: number;
|
||||||
|
artists: ArtistTrackPlaylistObject[];
|
||||||
|
album: AlbumTrackPlaylistObject;
|
||||||
|
ids: IDs;
|
||||||
|
disc_number: number;
|
||||||
|
track_number: number;
|
||||||
|
explicit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistObject {
|
||||||
|
type: "playlist";
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
owner: UserObject;
|
||||||
|
tracks: TrackPlaylistObject[];
|
||||||
|
images: { [key: string]: any }[];
|
||||||
|
ids: IDs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artist Module Models
|
||||||
|
|
||||||
|
export interface AlbumArtistObject {
|
||||||
|
type: "albumArtist";
|
||||||
|
album_type: string;
|
||||||
|
title: string;
|
||||||
|
release_date: { [key: string]: any };
|
||||||
|
total_tracks: number;
|
||||||
|
ids: IDs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistObject {
|
||||||
|
type: "artist";
|
||||||
|
name: string;
|
||||||
|
genres: string[];
|
||||||
|
images: { [key: string]: any }[];
|
||||||
|
ids: IDs;
|
||||||
|
albums: AlbumArtistObject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Album Module Models
|
||||||
|
|
||||||
|
export interface ArtistTrackAlbumObject {
|
||||||
|
type: "artistTrackAlbum";
|
||||||
|
name: string;
|
||||||
|
ids: IDs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistAlbumObject {
|
||||||
|
type: "artistAlbum";
|
||||||
|
name: string;
|
||||||
|
genres: string[];
|
||||||
|
ids: IDs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackAlbumObject {
|
||||||
|
type: "trackAlbum";
|
||||||
|
title: string;
|
||||||
|
disc_number: number;
|
||||||
|
track_number: number;
|
||||||
|
duration_ms: number;
|
||||||
|
explicit: boolean;
|
||||||
|
genres: string[];
|
||||||
|
ids: IDs;
|
||||||
|
artists: ArtistTrackAlbumObject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlbumObject {
|
||||||
|
type: "album";
|
||||||
|
album_type: string;
|
||||||
|
title: string;
|
||||||
|
release_date: { [key: string]: any };
|
||||||
|
total_tracks: number;
|
||||||
|
genres: string[];
|
||||||
|
images: { [key: string]: any }[];
|
||||||
|
copyrights: { [key: string]: string }[];
|
||||||
|
ids: IDs;
|
||||||
|
tracks: TrackAlbumObject[];
|
||||||
|
artists: ArtistAlbumObject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback Module Models
|
||||||
|
|
||||||
|
export interface BaseStatusObject {
|
||||||
|
ids?: IDs;
|
||||||
|
convert_to?: string;
|
||||||
|
bitrate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitializingObject extends BaseStatusObject {
|
||||||
|
status: "initializing";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkippedObject extends BaseStatusObject {
|
||||||
|
status: "skipped";
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetryingObject extends BaseStatusObject {
|
||||||
|
status: "retrying";
|
||||||
|
retry_count: number;
|
||||||
|
seconds_left: number;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RealTimeObject extends BaseStatusObject {
|
||||||
|
status: "real-time";
|
||||||
|
time_elapsed: number;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorObject extends BaseStatusObject {
|
||||||
|
status: "error";
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailedTrackObject {
|
||||||
|
track: TrackObject;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryObject {
|
||||||
|
successful_tracks: TrackObject[];
|
||||||
|
skipped_tracks: TrackObject[];
|
||||||
|
failed_tracks: FailedTrackObject[];
|
||||||
|
total_successful: number;
|
||||||
|
total_skipped: number;
|
||||||
|
total_failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DoneObject extends BaseStatusObject {
|
||||||
|
status: "done";
|
||||||
|
summary?: SummaryObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatusInfo =
|
||||||
|
| InitializingObject
|
||||||
|
| SkippedObject
|
||||||
|
| RetryingObject
|
||||||
|
| RealTimeObject
|
||||||
|
| ErrorObject
|
||||||
|
| DoneObject;
|
||||||
|
|
||||||
|
export interface TrackCallbackObject {
|
||||||
|
track: TrackObject;
|
||||||
|
status_info: StatusInfo;
|
||||||
|
current_track?: number;
|
||||||
|
total_tracks?: number;
|
||||||
|
parent?: AlbumTrackObject | PlaylistTrackObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlbumCallbackObject {
|
||||||
|
album: AlbumObject;
|
||||||
|
status_info: StatusInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistCallbackObject {
|
||||||
|
playlist: PlaylistObject;
|
||||||
|
status_info: StatusInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessingCallbackObject {
|
||||||
|
status: "processing";
|
||||||
|
timestamp: number;
|
||||||
|
type: "track" | "album" | "playlist";
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CallbackObject =
|
||||||
|
| TrackCallbackObject
|
||||||
|
| AlbumCallbackObject
|
||||||
|
| PlaylistCallbackObject
|
||||||
|
| ProcessingCallbackObject;
|
||||||
Reference in New Issue
Block a user