Fix #167, #161 and #156, implemented #155

This commit is contained in:
Xoconoch
2025-06-09 18:18:19 -06:00
parent ca77c0e9f3
commit 66da8cef5c
14 changed files with 1051 additions and 273 deletions

View File

@@ -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.8.0 deezspot-spotizerr==1.10.0

View File

@@ -15,20 +15,38 @@ def get_download_history():
sort_by = request.args.get("sort_by", "timestamp_completed") sort_by = request.args.get("sort_by", "timestamp_completed")
sort_order = request.args.get("sort_order", "DESC") sort_order = request.args.get("sort_order", "DESC")
# Basic filtering example: filter by status_final or download_type # Create filters dictionary for various filter options
filters = {} filters = {}
# Status filter
status_filter = request.args.get("status_final") status_filter = request.args.get("status_final")
if status_filter: if status_filter:
filters["status_final"] = status_filter filters["status_final"] = status_filter
# Download type filter
type_filter = request.args.get("download_type") type_filter = request.args.get("download_type")
if type_filter: if type_filter:
filters["download_type"] = type_filter filters["download_type"] = type_filter
# Add more filters as needed, e.g., by item_name (would need LIKE for partial match) # Parent task filter
# search_term = request.args.get('search') parent_task_filter = request.args.get("parent_task_id")
# if search_term: if parent_task_filter:
# filters['item_name'] = f'%{search_term}%' # This would require LIKE in get_history_entries filters["parent_task_id"] = parent_task_filter
# Track status filter
track_status_filter = request.args.get("track_status")
if track_status_filter:
filters["track_status"] = track_status_filter
# Show/hide child tracks
hide_child_tracks = request.args.get("hide_child_tracks", "false").lower() == "true"
if hide_child_tracks:
filters["parent_task_id"] = None # Only show parent entries or standalone tracks
# Show only tracks with specific parent
only_parent_tracks = request.args.get("only_parent_tracks", "false").lower() == "true"
if only_parent_tracks and not parent_task_filter:
filters["parent_task_id"] = "NOT_NULL" # Special value to indicate we want only child tracks
entries, total_count = get_history_entries( entries, total_count = get_history_entries(
limit, offset, sort_by, sort_order, filters limit, offset, sort_by, sort_order, filters
@@ -45,3 +63,34 @@ def get_download_history():
except Exception as e: except Exception as e:
logger.error(f"Error in /api/history endpoint: {e}", exc_info=True) logger.error(f"Error in /api/history endpoint: {e}", exc_info=True)
return jsonify({"error": "Failed to retrieve download history"}), 500 return jsonify({"error": "Failed to retrieve download history"}), 500
@history_bp.route("/tracks/<parent_task_id>", methods=["GET"])
def get_tracks_for_parent(parent_task_id):
"""API endpoint to retrieve all track entries for a specific parent task."""
try:
# We don't need pagination for this endpoint as we want all tracks for a parent
filters = {"parent_task_id": parent_task_id}
# Optional sorting
sort_by = request.args.get("sort_by", "timestamp_completed")
sort_order = request.args.get("sort_order", "DESC")
entries, total_count = get_history_entries(
limit=1000, # High limit to get all tracks
offset=0,
sort_by=sort_by,
sort_order=sort_order,
filters=filters
)
return jsonify(
{
"parent_task_id": parent_task_id,
"tracks": entries,
"total_count": total_count,
}
)
except Exception as e:
logger.error(f"Error in /api/history/tracks endpoint: {e}", exc_info=True)
return jsonify({"error": f"Failed to retrieve tracks for parent task {parent_task_id}"}), 500

View File

@@ -76,13 +76,21 @@ def get_task_details(task_id):
last_status = get_last_task_status(task_id) last_status = get_last_task_status(task_id)
status_count = len(get_task_status(task_id)) status_count = len(get_task_status(task_id))
# Default to the full last_status object, then check for the raw callback
last_line_content = last_status
if last_status and "raw_callback" in last_status:
last_line_content = last_status["raw_callback"]
response = { response = {
"original_url": dynamic_original_url, "original_url": dynamic_original_url,
"last_line": last_status, "last_line": last_line_content,
"timestamp": time.time(), "timestamp": time.time(),
"task_id": task_id, "task_id": task_id,
"status_count": status_count, "status_count": status_count,
} }
if last_status and last_status.get("summary"):
response["summary"] = last_status["summary"]
return jsonify(response) return jsonify(response)
@@ -122,33 +130,34 @@ 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:
detailed_tasks.append( task_details = {
{ "task_id": task_id,
"task_id": task_id, "type": task_info.get(
"type": task_info.get( "type", task_summary.get("type", "unknown")
"type", task_summary.get("type", "unknown") ),
), "name": task_info.get(
"name": task_info.get( "name", task_summary.get("name", "Unknown")
"name", task_summary.get("name", "Unknown") ),
), "artist": task_info.get(
"artist": task_info.get( "artist", task_summary.get("artist", "")
"artist", task_summary.get("artist", "") ),
), "download_type": task_info.get(
"download_type": task_info.get( "download_type",
"download_type", task_summary.get("download_type", "unknown"),
task_summary.get("download_type", "unknown"), ),
), "status": last_status.get(
"status": last_status.get( "status", "unknown"
"status", "unknown" ), # Keep summary status for quick access
), # Keep summary status for quick access "last_status_obj": last_status, # Full last status object
"last_status_obj": last_status, # Full last status object "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": last_status.get(
"timestamp": last_status.get( "timestamp", task_info.get("created_at", 0)
"timestamp", task_info.get("created_at", 0) ),
), }
} if last_status.get("summary"):
) task_details["summary"] = last_status["summary"]
detailed_tasks.append(task_details)
elif ( elif (
task_info task_info
): # If last_status is somehow missing, still provide some info ): # If last_status is somehow missing, still provide some info

View File

@@ -127,6 +127,7 @@ class CeleryDownloadQueueManager:
NON_BLOCKING_STATES = [ NON_BLOCKING_STATES = [
ProgressState.COMPLETE, ProgressState.COMPLETE,
ProgressState.DONE,
ProgressState.CANCELLED, ProgressState.CANCELLED,
ProgressState.ERROR, ProgressState.ERROR,
ProgressState.ERROR_RETRIED, ProgressState.ERROR_RETRIED,
@@ -354,7 +355,11 @@ class CeleryDownloadQueueManager:
status = task.get("status") status = task.get("status")
# Only cancel tasks that are not already completed or cancelled # Only cancel tasks that are not already completed or cancelled
if status not in [ProgressState.COMPLETE, ProgressState.CANCELLED]: if status not in [
ProgressState.COMPLETE,
ProgressState.DONE,
ProgressState.CANCELLED,
]:
result = cancel_celery_task(task_id) result = cancel_celery_task(task_id)
if result.get("status") == "cancelled": if result.get("status") == "cancelled":
cancelled_count += 1 cancelled_count += 1

View File

@@ -29,7 +29,7 @@ from routes.utils.watch.db import (
) )
# Import history manager function # Import history manager function
from .history_manager import add_entry_to_history from .history_manager import add_entry_to_history, add_tracks_from_summary
# Create Redis connection for storing task data that's not part of the Celery result backend # Create Redis connection for storing task data that's not part of the Celery result backend
import redis import redis
@@ -238,6 +238,9 @@ def _log_task_to_history(task_id, final_status_str, error_msg=None):
except Exception: except Exception:
spotify_id = None # Ignore errors in parsing spotify_id = None # Ignore errors in parsing
# Check for the new summary object in the last status
summary_obj = last_status_obj.get("summary") if last_status_obj else None
history_entry = { history_entry = {
"task_id": task_id, "task_id": task_id,
"download_type": task_info.get("download_type"), "download_type": task_info.get("download_type"),
@@ -271,15 +274,34 @@ def _log_task_to_history(task_id, final_status_str, error_msg=None):
"bitrate": bitrate_str "bitrate": bitrate_str
if bitrate_str if bitrate_str
else None, # Store None if empty string else None, # Store None if empty string
"summary_json": json.dumps(summary_obj) if summary_obj else None,
"total_successful": summary_obj.get("total_successful")
if summary_obj
else None,
"total_skipped": summary_obj.get("total_skipped") if summary_obj else None,
"total_failed": summary_obj.get("total_failed") if summary_obj else None,
} }
# Add the main history entry for the task
add_entry_to_history(history_entry) add_entry_to_history(history_entry)
# Process track-level entries from summary if this is a multi-track download
if summary_obj and task_info.get("download_type") in ["album", "playlist"]:
tracks_processed = add_tracks_from_summary(
summary_data=summary_obj,
parent_task_id=task_id,
parent_history_data=history_entry
)
logger.info(
f"Track-level history: Processed {tracks_processed['successful']} successful, "
f"{tracks_processed['skipped']} skipped, and {tracks_processed['failed']} failed tracks for task {task_id}"
)
except Exception as e: except Exception as e:
logger.error( logger.error(
f"History: Error preparing or logging history for task {task_id}: {e}", f"History: Error preparing or logging history for task {task_id}: {e}",
exc_info=True, exc_info=True,
) )
# --- End History Logging Helper --- # --- End History Logging Helper ---
@@ -536,6 +558,9 @@ class ProgressTrackingTask(Task):
Args: Args:
progress_data: Dictionary containing progress information from deezspot progress_data: Dictionary containing progress information from deezspot
""" """
# Store a copy of the original, unprocessed callback data
raw_callback_data = progress_data.copy()
task_id = self.request.id task_id = self.request.id
# Ensure ./logs/tasks directory exists # Ensure ./logs/tasks directory exists
@@ -570,9 +595,6 @@ class ProgressTrackingTask(Task):
# Get status type # Get status type
status = progress_data.get("status", "unknown") status = progress_data.get("status", "unknown")
# Create a work copy of the data to avoid modifying the original
stored_data = progress_data.copy()
# Get task info for context # Get task info for context
task_info = get_task_info(task_id) task_info = get_task_info(task_id)
@@ -585,44 +607,47 @@ class ProgressTrackingTask(Task):
# Process based on status type using a more streamlined approach # Process based on status type using a more streamlined approach
if status == "initializing": if status == "initializing":
# --- INITIALIZING: Start of a download operation --- # --- INITIALIZING: Start of a download operation ---
self._handle_initializing(task_id, stored_data, task_info) self._handle_initializing(task_id, progress_data, task_info)
elif status == "downloading": elif status == "downloading":
# --- DOWNLOADING: Track download started --- # --- DOWNLOADING: Track download started ---
self._handle_downloading(task_id, stored_data, task_info) self._handle_downloading(task_id, progress_data, task_info)
elif status == "progress": elif status == "progress":
# --- PROGRESS: Album/playlist track progress --- # --- PROGRESS: Album/playlist track progress ---
self._handle_progress(task_id, stored_data, task_info) self._handle_progress(task_id, progress_data, task_info)
elif status == "real_time" or status == "track_progress": elif status == "real_time" or status == "track_progress":
# --- REAL_TIME/TRACK_PROGRESS: Track download real-time progress --- # --- REAL_TIME/TRACK_PROGRESS: Track download real-time progress ---
self._handle_real_time(task_id, stored_data) self._handle_real_time(task_id, progress_data)
elif status == "skipped": elif status == "skipped":
# --- SKIPPED: Track was skipped --- # --- SKIPPED: Track was skipped ---
self._handle_skipped(task_id, stored_data, task_info) self._handle_skipped(task_id, progress_data, task_info)
elif status == "retrying": elif status == "retrying":
# --- RETRYING: Download failed and being retried --- # --- RETRYING: Download failed and being retried ---
self._handle_retrying(task_id, stored_data, task_info) self._handle_retrying(task_id, progress_data, task_info)
elif status == "error": elif status == "error":
# --- ERROR: Error occurred during download --- # --- ERROR: Error occurred during download ---
self._handle_error(task_id, stored_data, task_info) self._handle_error(task_id, progress_data, task_info)
elif status == "done": elif status == "done":
# --- DONE: Download operation completed --- # --- DONE: Download operation completed ---
self._handle_done(task_id, stored_data, task_info) self._handle_done(task_id, progress_data, task_info)
else: else:
# --- UNKNOWN: Unrecognized status --- # --- UNKNOWN: Unrecognized status ---
logger.info( logger.info(
f"Task {task_id} {status}: {stored_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
# Store the processed status update # Store the processed status update
store_task_status(task_id, stored_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"""
@@ -663,7 +688,7 @@ class ProgressTrackingTask(Task):
store_task_info(task_id, task_info) store_task_info(task_id, task_info)
# Update status in data # Update status in data
data["status"] = ProgressState.INITIALIZING # 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"""
@@ -720,7 +745,7 @@ class ProgressTrackingTask(Task):
logger.info(f"Task {task_id} downloading: '{track_name}'") logger.info(f"Task {task_id} downloading: '{track_name}'")
# Update status # Update status
data["status"] = ProgressState.DOWNLOADING # 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 from deezspot"""
@@ -776,7 +801,7 @@ class ProgressTrackingTask(Task):
logger.error(f"Error parsing track numbers '{current_track_raw}': {e}") logger.error(f"Error parsing track numbers '{current_track_raw}': {e}")
# Ensure correct status # Ensure correct status
data["status"] = ProgressState.PROGRESS # data["status"] = ProgressState.PROGRESS
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"""
@@ -818,11 +843,11 @@ class ProgressTrackingTask(Task):
logger.debug(f"Task {task_id} track progress: {title} by {artist}: {percent}%") logger.debug(f"Task {task_id} track progress: {title} by {artist}: {percent}%")
# Set appropriate status # Set appropriate status
data["status"] = ( # data["status"] = (
ProgressState.REAL_TIME # ProgressState.REAL_TIME
if data.get("status") == "real_time" # if data.get("status") == "real_time"
else ProgressState.TRACK_PROGRESS # else ProgressState.TRACK_PROGRESS
) # )
def _handle_skipped(self, task_id, data, task_info): def _handle_skipped(self, task_id, data, task_info):
"""Handle skipped status from deezspot""" """Handle skipped status from deezspot"""
@@ -872,7 +897,7 @@ class ProgressTrackingTask(Task):
store_task_status(task_id, progress_update) store_task_status(task_id, progress_update)
# Set status # Set status
data["status"] = ProgressState.SKIPPED # data["status"] = ProgressState.SKIPPED
def _handle_retrying(self, task_id, data, task_info): def _handle_retrying(self, task_id, data, task_info):
"""Handle retrying status from deezspot""" """Handle retrying status from deezspot"""
@@ -895,7 +920,7 @@ class ProgressTrackingTask(Task):
store_task_info(task_id, task_info) store_task_info(task_id, task_info)
# Set status # Set status
data["status"] = ProgressState.RETRYING # data["status"] = ProgressState.RETRYING
def _handle_error(self, task_id, data, task_info): def _handle_error(self, task_id, data, task_info):
"""Handle error status from deezspot""" """Handle error status from deezspot"""
@@ -911,7 +936,7 @@ class ProgressTrackingTask(Task):
store_task_info(task_id, task_info) store_task_info(task_id, task_info)
# Set status and error message # Set status and error message
data["status"] = ProgressState.ERROR # data["status"] = ProgressState.ERROR
data["error"] = message data["error"] = message
def _handle_done(self, task_id, data, task_info): def _handle_done(self, task_id, data, task_info):
@@ -931,7 +956,7 @@ class ProgressTrackingTask(Task):
logger.info(f"Task {task_id} completed: Track '{song}'") logger.info(f"Task {task_id} completed: Track '{song}'")
# Update status to track_complete # Update status to track_complete
data["status"] = ProgressState.TRACK_COMPLETE # data["status"] = ProgressState.TRACK_COMPLETE
# Update task info # Update task info
completed_tracks = task_info.get("completed_tracks", 0) + 1 completed_tracks = task_info.get("completed_tracks", 0) + 1
@@ -989,15 +1014,28 @@ class ProgressTrackingTask(Task):
logger.info(f"Task {task_id} completed: {content_type.upper()}") logger.info(f"Task {task_id} completed: {content_type.upper()}")
# Add summary # Add summary
data["status"] = ProgressState.COMPLETE # data["status"] = ProgressState.COMPLETE
data["message"] = ( summary_obj = data.get("summary")
f"Download complete: {completed_tracks} tracks downloaded, {skipped_tracks} skipped"
)
# Log summary if summary_obj:
logger.info( total_successful = summary_obj.get("total_successful", 0)
f"Task {task_id} summary: {completed_tracks} completed, {skipped_tracks} skipped, {error_count} errors" total_skipped = summary_obj.get("total_skipped", 0)
) total_failed = summary_obj.get("total_failed", 0)
# data[
# "message"
# ] = f"Download complete: {total_successful} tracks downloaded, {total_skipped} skipped, {total_failed} failed."
# Log summary from the summary object
logger.info(
f"Task {task_id} summary: {total_successful} successful, {total_skipped} skipped, {total_failed} failed."
)
else:
# data["message"] = (
# f"Download complete: {completed_tracks} tracks downloaded, {skipped_tracks} skipped"
# )
# Log summary
logger.info(
f"Task {task_id} summary: {completed_tracks} completed, {skipped_tracks} skipped, {error_count} errors"
)
# Schedule deletion for completed multi-track downloads # Schedule deletion for completed multi-track downloads
delayed_delete_task_data.apply_async( delayed_delete_task_data.apply_async(
args=[task_id, "Task completed successfully and auto-cleaned."], args=[task_id, "Task completed successfully and auto-cleaned."],
@@ -1066,8 +1104,8 @@ class ProgressTrackingTask(Task):
else: else:
# Generic done for other types # Generic done for other types
logger.info(f"Task {task_id} completed: {content_type.upper()}") logger.info(f"Task {task_id} completed: {content_type.upper()}")
data["status"] = ProgressState.COMPLETE # data["status"] = ProgressState.COMPLETE
data["message"] = "Download complete" # data["message"] = "Download complete"
# Celery signal handlers # Celery signal handlers
@@ -1134,18 +1172,11 @@ def task_postrun_handler(
) )
if state == states.SUCCESS: if state == states.SUCCESS:
if current_redis_status != ProgressState.COMPLETE: if current_redis_status not in [ProgressState.COMPLETE, "done"]:
store_task_status( # The final status is now set by the 'done' callback from deezspot.
task_id, # We no longer need to store a generic 'COMPLETE' status here.
{ # This ensures the raw callback data is the last thing in the log.
"status": ProgressState.COMPLETE, pass
"timestamp": time.time(),
"type": task_info.get("type", "unknown"),
"name": task_info.get("name", "Unknown"),
"artist": task_info.get("artist", ""),
"message": "Download completed successfully.",
},
)
logger.info( logger.info(
f"Task {task_id} completed successfully: {task_info.get('name', 'Unknown')}" f"Task {task_id} completed successfully: {task_info.get('name', 'Unknown')}"
) )

View File

@@ -2,6 +2,7 @@ import sqlite3
import json import json
import time import time
import logging import logging
import uuid
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -27,6 +28,12 @@ EXPECTED_COLUMNS = {
"quality_profile": "TEXT", "quality_profile": "TEXT",
"convert_to": "TEXT", "convert_to": "TEXT",
"bitrate": "TEXT", "bitrate": "TEXT",
"parent_task_id": "TEXT", # Reference to parent task for individual tracks
"track_status": "TEXT", # 'SUCCESSFUL', 'SKIPPED', 'FAILED'
"summary_json": "TEXT", # JSON string of the summary object from task
"total_successful": "INTEGER", # Count of successful tracks
"total_skipped": "INTEGER", # Count of skipped tracks
"total_failed": "INTEGER", # Count of failed tracks
} }
@@ -61,7 +68,13 @@ def init_history_db():
service_used TEXT, service_used TEXT,
quality_profile TEXT, quality_profile TEXT,
convert_to TEXT, convert_to TEXT,
bitrate TEXT bitrate TEXT,
parent_task_id TEXT,
track_status TEXT,
summary_json TEXT,
total_successful INTEGER,
total_skipped INTEGER,
total_failed INTEGER
) )
""" """
cursor.execute(create_table_sql) cursor.execute(create_table_sql)
@@ -106,6 +119,27 @@ def init_history_db():
f"Could not add column '{col_name}': {alter_e}. It might already exist or there's a schema mismatch." f"Could not add column '{col_name}': {alter_e}. It might already exist or there's a schema mismatch."
) )
# Add additional columns for summary data if they don't exist
for col_name, col_type in {
"summary_json": "TEXT",
"total_successful": "INTEGER",
"total_skipped": "INTEGER",
"total_failed": "INTEGER"
}.items():
if col_name not in existing_column_names and col_name not in EXPECTED_COLUMNS:
try:
cursor.execute(
f"ALTER TABLE download_history ADD COLUMN {col_name} {col_type}"
)
logger.info(
f"Added missing column '{col_name} {col_type}' to download_history table."
)
added_columns = True
except sqlite3.OperationalError as alter_e:
logger.warning(
f"Could not add column '{col_name}': {alter_e}. It might already exist or there's a schema mismatch."
)
if added_columns: if added_columns:
conn.commit() conn.commit()
logger.info(f"Download history table schema updated at {HISTORY_DB_FILE}") logger.info(f"Download history table schema updated at {HISTORY_DB_FILE}")
@@ -148,6 +182,12 @@ def add_entry_to_history(history_data: dict):
"quality_profile", "quality_profile",
"convert_to", "convert_to",
"bitrate", "bitrate",
"parent_task_id",
"track_status",
"summary_json",
"total_successful",
"total_skipped",
"total_failed",
] ]
# Ensure all keys are present, filling with None if not # Ensure all keys are present, filling with None if not
for key in required_keys: for key in required_keys:
@@ -164,8 +204,9 @@ def add_entry_to_history(history_data: dict):
item_url, spotify_id, status_final, error_message, item_url, spotify_id, status_final, error_message,
timestamp_added, timestamp_completed, original_request_json, timestamp_added, timestamp_completed, original_request_json,
last_status_obj_json, service_used, quality_profile, last_status_obj_json, service_used, quality_profile,
convert_to, bitrate convert_to, bitrate, parent_task_id, track_status,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) summary_json, total_successful, total_skipped, total_failed
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
history_data["task_id"], history_data["task_id"],
@@ -185,6 +226,12 @@ def add_entry_to_history(history_data: dict):
history_data["quality_profile"], history_data["quality_profile"],
history_data["convert_to"], history_data["convert_to"],
history_data["bitrate"], history_data["bitrate"],
history_data["parent_task_id"],
history_data["track_status"],
history_data["summary_json"],
history_data["total_successful"],
history_data["total_skipped"],
history_data["total_failed"],
), ),
) )
conn.commit() conn.commit()
@@ -239,8 +286,16 @@ def get_history_entries(
for column, value in filters.items(): for column, value in filters.items():
# Basic security: ensure column is a valid one (alphanumeric + underscore) # Basic security: ensure column is a valid one (alphanumeric + underscore)
if column.replace("_", "").isalnum(): if column.replace("_", "").isalnum():
where_clauses.append(f"{column} = ?") # Special case for 'NOT_NULL' value for parent_task_id
params.append(value) if column == "parent_task_id" and value == "NOT_NULL":
where_clauses.append(f"{column} IS NOT NULL")
# Regular case for NULL value
elif value is None:
where_clauses.append(f"{column} IS NULL")
# Regular case for exact match
else:
where_clauses.append(f"{column} = ?")
params.append(value)
if where_clauses: if where_clauses:
where_sql = " WHERE " + " AND ".join(where_clauses) where_sql = " WHERE " + " AND ".join(where_clauses)
@@ -266,6 +321,11 @@ def get_history_entries(
"quality_profile", "quality_profile",
"convert_to", "convert_to",
"bitrate", "bitrate",
"parent_task_id",
"track_status",
"total_successful",
"total_skipped",
"total_failed",
] ]
if sort_by not in valid_sort_columns: if sort_by not in valid_sort_columns:
sort_by = "timestamp_completed" # Default sort sort_by = "timestamp_completed" # Default sort
@@ -292,6 +352,157 @@ def get_history_entries(
conn.close() conn.close()
def add_track_entry_to_history(track_name, artist_name, parent_task_id, track_status, parent_history_data=None):
"""Adds a track-specific entry to the history database.
Args:
track_name (str): The name of the track
artist_name (str): The artist name
parent_task_id (str): The ID of the parent task (album or playlist)
track_status (str): The status of the track ('SUCCESSFUL', 'SKIPPED', 'FAILED')
parent_history_data (dict, optional): The history data of the parent task
Returns:
str: The task_id of the created track entry
"""
# Generate a unique ID for this track entry
track_task_id = f"{parent_task_id}_track_{uuid.uuid4().hex[:8]}"
# Create a copy of parent data or initialize empty dict
track_history_data = {}
if parent_history_data:
# Copy relevant fields from parent
for key in EXPECTED_COLUMNS:
if key in parent_history_data and key not in ['task_id', 'item_name', 'item_artist']:
track_history_data[key] = parent_history_data[key]
# Set track-specific fields
track_history_data.update({
"task_id": track_task_id,
"download_type": "track",
"item_name": track_name,
"item_artist": artist_name,
"parent_task_id": parent_task_id,
"track_status": track_status,
"status_final": "COMPLETED" if track_status == "SUCCESSFUL" else
"SKIPPED" if track_status == "SKIPPED" else "ERROR",
"timestamp_completed": time.time()
})
# Extract track URL if possible (from last_status_obj_json)
if parent_history_data and parent_history_data.get("last_status_obj_json"):
try:
last_status = json.loads(parent_history_data["last_status_obj_json"])
# Try to match track name in the tracks lists to find URL
track_key = f"{track_name} - {artist_name}"
if "raw_callback" in last_status and last_status["raw_callback"].get("url"):
track_history_data["item_url"] = last_status["raw_callback"].get("url")
# Extract Spotify ID from URL if possible
url = last_status["raw_callback"].get("url", "")
if url and "spotify.com" in url:
try:
spotify_id = url.split("/")[-1]
if spotify_id and len(spotify_id) == 22 and spotify_id.isalnum():
track_history_data["spotify_id"] = spotify_id
except Exception:
pass
except (json.JSONDecodeError, KeyError, AttributeError) as e:
logger.warning(f"Could not extract track URL for {track_name}: {e}")
# Add entry to history
add_entry_to_history(track_history_data)
return track_task_id
def add_tracks_from_summary(summary_data, parent_task_id, parent_history_data=None):
"""Processes a summary object from a completed task and adds individual track entries.
Args:
summary_data (dict): The summary data containing track lists
parent_task_id (str): The ID of the parent task
parent_history_data (dict, optional): The history data of the parent task
Returns:
dict: Summary of processed tracks
"""
processed = {
"successful": 0,
"skipped": 0,
"failed": 0
}
if not summary_data:
logger.warning(f"No summary data provided for task {parent_task_id}")
return processed
# Process successful tracks
for track_entry in summary_data.get("successful_tracks", []):
try:
# Parse "track_name - artist_name" format
parts = track_entry.split(" - ", 1)
if len(parts) == 2:
track_name, artist_name = parts
add_track_entry_to_history(
track_name=track_name,
artist_name=artist_name,
parent_task_id=parent_task_id,
track_status="SUCCESSFUL",
parent_history_data=parent_history_data
)
processed["successful"] += 1
else:
logger.warning(f"Could not parse track entry: {track_entry}")
except Exception as e:
logger.error(f"Error processing successful track {track_entry}: {e}", exc_info=True)
# Process skipped tracks
for track_entry in summary_data.get("skipped_tracks", []):
try:
parts = track_entry.split(" - ", 1)
if len(parts) == 2:
track_name, artist_name = parts
add_track_entry_to_history(
track_name=track_name,
artist_name=artist_name,
parent_task_id=parent_task_id,
track_status="SKIPPED",
parent_history_data=parent_history_data
)
processed["skipped"] += 1
else:
logger.warning(f"Could not parse skipped track entry: {track_entry}")
except Exception as e:
logger.error(f"Error processing skipped track {track_entry}: {e}", exc_info=True)
# Process failed tracks
for track_entry in summary_data.get("failed_tracks", []):
try:
parts = track_entry.split(" - ", 1)
if len(parts) == 2:
track_name, artist_name = parts
add_track_entry_to_history(
track_name=track_name,
artist_name=artist_name,
parent_task_id=parent_task_id,
track_status="FAILED",
parent_history_data=parent_history_data
)
processed["failed"] += 1
else:
logger.warning(f"Could not parse failed track entry: {track_entry}")
except Exception as e:
logger.error(f"Error processing failed track {track_entry}: {e}", exc_info=True)
logger.info(
f"Added {processed['successful']} successful, {processed['skipped']} skipped, "
f"and {processed['failed']} failed track entries for task {parent_task_id}"
)
return processed
if __name__ == "__main__": if __name__ == "__main__":
# For testing purposes # For testing purposes
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)

View File

@@ -124,6 +124,10 @@ def download_playlist(
"spotify", main "spotify", main
) # For blob path ) # For blob path
blob_file_path = spotify_main_creds.get("blob_file_path") blob_file_path = spotify_main_creds.get("blob_file_path")
if blob_file_path is None:
raise ValueError(
f"Spotify credentials for account '{main}' don't contain a blob_file_path. Please check your credentials configuration."
)
if not Path(blob_file_path).exists(): if not Path(blob_file_path).exists():
raise FileNotFoundError( raise FileNotFoundError(
f"Spotify credentials blob file not found at {blob_file_path} for account '{main}'" f"Spotify credentials blob file not found at {blob_file_path} for account '{main}'"
@@ -180,6 +184,10 @@ def download_playlist(
spotify_main_creds = get_credential("spotify", main) # For blob path spotify_main_creds = get_credential("spotify", main) # For blob path
blob_file_path = spotify_main_creds.get("blob_file_path") blob_file_path = spotify_main_creds.get("blob_file_path")
if blob_file_path is None:
raise ValueError(
f"Spotify credentials for account '{main}' don't contain a blob_file_path. Please check your credentials configuration."
)
if not Path(blob_file_path).exists(): if not Path(blob_file_path).exists():
raise FileNotFoundError( raise FileNotFoundError(
f"Spotify credentials blob file not found at {blob_file_path} for account '{main}'" f"Spotify credentials blob file not found at {blob_file_path} for account '{main}'"

View File

@@ -6,12 +6,15 @@ document.addEventListener('DOMContentLoaded', () => {
const limitSelect = document.getElementById('limit-select') as HTMLSelectElement | null; const limitSelect = document.getElementById('limit-select') as HTMLSelectElement | null;
const statusFilter = document.getElementById('status-filter') as HTMLSelectElement | null; const statusFilter = document.getElementById('status-filter') as HTMLSelectElement | null;
const typeFilter = document.getElementById('type-filter') as HTMLSelectElement | null; const typeFilter = document.getElementById('type-filter') as HTMLSelectElement | null;
const trackFilter = document.getElementById('track-filter') as HTMLSelectElement | null;
const hideChildTracksCheckbox = document.getElementById('hide-child-tracks') as HTMLInputElement | null;
let currentPage = 1; let currentPage = 1;
let limit = 25; let limit = 25;
let totalEntries = 0; let totalEntries = 0;
let currentSortBy = 'timestamp_completed'; let currentSortBy = 'timestamp_completed';
let currentSortOrder = 'DESC'; let currentSortOrder = 'DESC';
let currentParentTaskId: string | null = null;
async function fetchHistory(page = 1) { async function fetchHistory(page = 1) {
if (!historyTableBody || !prevButton || !nextButton || !pageInfo || !limitSelect || !statusFilter || !typeFilter) { if (!historyTableBody || !prevButton || !nextButton || !pageInfo || !limitSelect || !statusFilter || !typeFilter) {
@@ -30,6 +33,21 @@ document.addEventListener('DOMContentLoaded', () => {
if (typeVal) { if (typeVal) {
apiUrl += `&download_type=${typeVal}`; apiUrl += `&download_type=${typeVal}`;
} }
// Add track status filter if present
if (trackFilter && trackFilter.value) {
apiUrl += `&track_status=${trackFilter.value}`;
}
// Add parent task filter if viewing a specific parent's tracks
if (currentParentTaskId) {
apiUrl += `&parent_task_id=${currentParentTaskId}`;
}
// Add hide child tracks filter if checkbox is checked
if (hideChildTracksCheckbox && hideChildTracksCheckbox.checked) {
apiUrl += `&hide_child_tracks=true`;
}
try { try {
const response = await fetch(apiUrl); const response = await fetch(apiUrl);
@@ -42,10 +60,13 @@ document.addEventListener('DOMContentLoaded', () => {
currentPage = Math.floor(offset / limit) + 1; currentPage = Math.floor(offset / limit) + 1;
updatePagination(); updatePagination();
updateSortIndicators(); updateSortIndicators();
// Update page title if viewing tracks for a parent
updatePageTitle();
} catch (error) { } catch (error) {
console.error('Error fetching history:', error); console.error('Error fetching history:', error);
if (historyTableBody) { if (historyTableBody) {
historyTableBody.innerHTML = '<tr><td colspan="9">Error loading history.</td></tr>'; historyTableBody.innerHTML = '<tr><td colspan="10">Error loading history.</td></tr>';
} }
} }
} }
@@ -55,17 +76,43 @@ document.addEventListener('DOMContentLoaded', () => {
historyTableBody.innerHTML = ''; // Clear existing rows historyTableBody.innerHTML = ''; // Clear existing rows
if (!entries || entries.length === 0) { if (!entries || entries.length === 0) {
historyTableBody.innerHTML = '<tr><td colspan="9">No history entries found.</td></tr>'; historyTableBody.innerHTML = '<tr><td colspan="10">No history entries found.</td></tr>';
return; return;
} }
entries.forEach(entry => { entries.forEach(entry => {
const row = historyTableBody.insertRow(); const row = historyTableBody.insertRow();
row.insertCell().textContent = entry.item_name || 'N/A';
// Add class for parent/child styling
if (entry.parent_task_id) {
row.classList.add('child-track-row');
} else if (entry.download_type === 'album' || entry.download_type === 'playlist') {
row.classList.add('parent-task-row');
}
// Item name with indentation for child tracks
const nameCell = row.insertCell();
if (entry.parent_task_id) {
nameCell.innerHTML = `<span class="child-track-indent">└─ </span>${entry.item_name || 'N/A'}`;
} else {
nameCell.textContent = entry.item_name || 'N/A';
}
row.insertCell().textContent = entry.item_artist || 'N/A'; row.insertCell().textContent = entry.item_artist || 'N/A';
row.insertCell().textContent = entry.download_type ? entry.download_type.charAt(0).toUpperCase() + entry.download_type.slice(1) : 'N/A';
// Type cell - show track status for child tracks
const typeCell = row.insertCell();
if (entry.parent_task_id && entry.track_status) {
typeCell.textContent = entry.track_status;
typeCell.classList.add(`track-status-${entry.track_status.toLowerCase()}`);
} else {
typeCell.textContent = entry.download_type ? entry.download_type.charAt(0).toUpperCase() + entry.download_type.slice(1) : 'N/A';
}
row.insertCell().textContent = entry.service_used || 'N/A'; row.insertCell().textContent = entry.service_used || 'N/A';
// Construct Quality display string // Construct Quality display string
const qualityCell = row.insertCell();
let qualityDisplay = entry.quality_profile || 'N/A'; let qualityDisplay = entry.quality_profile || 'N/A';
if (entry.convert_to) { if (entry.convert_to) {
qualityDisplay = `${entry.convert_to.toUpperCase()}`; qualityDisplay = `${entry.convert_to.toUpperCase()}`;
@@ -76,22 +123,47 @@ document.addEventListener('DOMContentLoaded', () => {
} else if (entry.bitrate) { // Case where convert_to might not be set, but bitrate is (e.g. for OGG Vorbis quality settings) } else if (entry.bitrate) { // Case where convert_to might not be set, but bitrate is (e.g. for OGG Vorbis quality settings)
qualityDisplay = `${entry.bitrate}k (${entry.quality_profile || 'Profile'})`; qualityDisplay = `${entry.bitrate}k (${entry.quality_profile || 'Profile'})`;
} }
row.insertCell().textContent = qualityDisplay; qualityCell.textContent = qualityDisplay;
const statusCell = row.insertCell(); const statusCell = row.insertCell();
statusCell.textContent = entry.status_final || 'N/A'; statusCell.textContent = entry.status_final || 'N/A';
statusCell.className = `status-${entry.status_final}`; statusCell.className = `status-${entry.status_final?.toLowerCase() || 'unknown'}`;
row.insertCell().textContent = entry.timestamp_added ? new Date(entry.timestamp_added * 1000).toLocaleString() : 'N/A'; row.insertCell().textContent = entry.timestamp_added ? new Date(entry.timestamp_added * 1000).toLocaleString() : 'N/A';
row.insertCell().textContent = entry.timestamp_completed ? new Date(entry.timestamp_completed * 1000).toLocaleString() : 'N/A'; row.insertCell().textContent = entry.timestamp_completed ? new Date(entry.timestamp_completed * 1000).toLocaleString() : 'N/A';
const detailsCell = row.insertCell(); const actionsCell = row.insertCell();
// Add details button
const detailsButton = document.createElement('button'); const detailsButton = document.createElement('button');
detailsButton.innerHTML = `<img src="/static/images/info.svg" alt="Details">`; detailsButton.innerHTML = `<img src="/static/images/info.svg" alt="Details">`;
detailsButton.className = 'details-btn btn-icon'; detailsButton.className = 'details-btn btn-icon';
detailsButton.title = 'Show Details'; detailsButton.title = 'Show Details';
detailsButton.onclick = () => showDetailsModal(entry); detailsButton.onclick = () => showDetailsModal(entry);
detailsCell.appendChild(detailsButton); actionsCell.appendChild(detailsButton);
// Add view tracks button for album/playlist entries with child tracks
if (!entry.parent_task_id && (entry.download_type === 'album' || entry.download_type === 'playlist') &&
(entry.total_successful > 0 || entry.total_skipped > 0 || entry.total_failed > 0)) {
const viewTracksButton = document.createElement('button');
viewTracksButton.innerHTML = `<img src="/static/images/list.svg" alt="Tracks">`;
viewTracksButton.className = 'tracks-btn btn-icon';
viewTracksButton.title = 'View Tracks';
viewTracksButton.setAttribute('data-task-id', entry.task_id);
viewTracksButton.onclick = () => viewTracksForParent(entry.task_id);
actionsCell.appendChild(viewTracksButton);
// Add track counts display
const trackCountsSpan = document.createElement('span');
trackCountsSpan.className = 'track-counts';
trackCountsSpan.title = `Successful: ${entry.total_successful || 0}, Skipped: ${entry.total_skipped || 0}, Failed: ${entry.total_failed || 0}`;
trackCountsSpan.innerHTML = `
<span class="track-count success">${entry.total_successful || 0}</span> /
<span class="track-count skipped">${entry.total_skipped || 0}</span> /
<span class="track-count failed">${entry.total_failed || 0}</span>
`;
actionsCell.appendChild(trackCountsSpan);
}
if (entry.status_final === 'ERROR' && entry.error_message) { if (entry.status_final === 'ERROR' && entry.error_message) {
const errorSpan = document.createElement('span'); const errorSpan = document.createElement('span');
@@ -105,10 +177,8 @@ document.addEventListener('DOMContentLoaded', () => {
errorDetailsDiv = document.createElement('div'); errorDetailsDiv = document.createElement('div');
errorDetailsDiv.className = 'error-details'; errorDetailsDiv.className = 'error-details';
const newCell = row.insertCell(); // This will append to the end of the row const newCell = row.insertCell(); // This will append to the end of the row
newCell.colSpan = 9; // Span across all columns newCell.colSpan = 10; // Span across all columns
newCell.appendChild(errorDetailsDiv); newCell.appendChild(errorDetailsDiv);
// Visually, this new cell will be after the 'Details' button cell.
// To make it appear as part of the status cell or below the row, more complex DOM manipulation or CSS would be needed.
} }
errorDetailsDiv.textContent = entry.error_message; errorDetailsDiv.textContent = entry.error_message;
// Toggle display by directly manipulating the style of the details div // Toggle display by directly manipulating the style of the details div
@@ -127,27 +197,92 @@ document.addEventListener('DOMContentLoaded', () => {
prevButton.disabled = currentPage === 1; prevButton.disabled = currentPage === 1;
nextButton.disabled = currentPage === totalPages; nextButton.disabled = currentPage === totalPages;
} }
function updatePageTitle() {
const titleElement = document.getElementById('history-title');
if (!titleElement) return;
if (currentParentTaskId) {
titleElement.textContent = 'Download History - Viewing Tracks';
// Add back button
if (!document.getElementById('back-to-history')) {
const backButton = document.createElement('button');
backButton.id = 'back-to-history';
backButton.className = 'btn btn-secondary';
backButton.innerHTML = '&larr; Back to All History';
backButton.onclick = () => {
currentParentTaskId = null;
updatePageTitle();
fetchHistory(1);
};
titleElement.parentNode?.insertBefore(backButton, titleElement);
}
} else {
titleElement.textContent = 'Download History';
// Remove back button if it exists
const backButton = document.getElementById('back-to-history');
if (backButton) {
backButton.remove();
}
}
}
function showDetailsModal(entry: any) { function showDetailsModal(entry: any) {
const details = `Task ID: ${entry.task_id}\n` + // Create more detailed modal content with new fields
`Type: ${entry.download_type}\n` + let details = `Task ID: ${entry.task_id}\n` +
`Name: ${entry.item_name}\n` + `Type: ${entry.download_type}\n` +
`Artist: ${entry.item_artist}\n` + `Name: ${entry.item_name}\n` +
`Album: ${entry.item_album || 'N/A'}\n` + `Artist: ${entry.item_artist}\n` +
`URL: ${entry.item_url}\n` + `Album: ${entry.item_album || 'N/A'}\n` +
`Spotify ID: ${entry.spotify_id || 'N/A'}\n` + `URL: ${entry.item_url || 'N/A'}\n` +
`Service Used: ${entry.service_used || 'N/A'}\n` + `Spotify ID: ${entry.spotify_id || 'N/A'}\n` +
`Quality Profile (Original): ${entry.quality_profile || 'N/A'}\n` + `Service Used: ${entry.service_used || 'N/A'}\n` +
`ConvertTo: ${entry.convert_to || 'N/A'}\n` + `Quality Profile (Original): ${entry.quality_profile || 'N/A'}\n` +
`Bitrate: ${entry.bitrate ? entry.bitrate + 'k' : 'N/A'}\n` + `ConvertTo: ${entry.convert_to || 'N/A'}\n` +
`Status: ${entry.status_final}\n` + `Bitrate: ${entry.bitrate ? entry.bitrate + 'k' : 'N/A'}\n` +
`Error: ${entry.error_message || 'None'}\n` + `Status: ${entry.status_final}\n` +
`Added: ${new Date(entry.timestamp_added * 1000).toLocaleString()}\n` + `Error: ${entry.error_message || 'None'}\n` +
`Completed/Ended: ${new Date(entry.timestamp_completed * 1000).toLocaleString()}\n\n` + `Added: ${new Date(entry.timestamp_added * 1000).toLocaleString()}\n` +
`Original Request: ${JSON.stringify(JSON.parse(entry.original_request_json || '{}'), null, 2)}\n\n` + `Completed/Ended: ${new Date(entry.timestamp_completed * 1000).toLocaleString()}\n`;
`Last Status Object: ${JSON.stringify(JSON.parse(entry.last_status_obj_json || '{}'), null, 2)}`;
// Add track-specific details if this is a track
if (entry.parent_task_id) {
details += `Parent Task ID: ${entry.parent_task_id}\n` +
`Track Status: ${entry.track_status || 'N/A'}\n`;
}
// Add summary details if this is a parent task
if (entry.total_successful !== null || entry.total_skipped !== null || entry.total_failed !== null) {
details += `\nTrack Summary:\n` +
`Successful: ${entry.total_successful || 0}\n` +
`Skipped: ${entry.total_skipped || 0}\n` +
`Failed: ${entry.total_failed || 0}\n`;
}
details += `\nOriginal Request: ${JSON.stringify(JSON.parse(entry.original_request_json || '{}'), null, 2)}\n\n` +
`Last Status Object: ${JSON.stringify(JSON.parse(entry.last_status_obj_json || '{}'), null, 2)}`;
// Try to parse and display summary if available
if (entry.summary_json) {
try {
const summary = JSON.parse(entry.summary_json);
details += `\nSummary: ${JSON.stringify(summary, null, 2)}`;
} catch (e) {
console.error('Error parsing summary JSON:', e);
}
}
alert(details); alert(details);
} }
// Function to view tracks for a parent task
async function viewTracksForParent(taskId: string) {
currentParentTaskId = taskId;
currentPage = 1;
fetchHistory(1);
}
document.querySelectorAll('th[data-sort]').forEach(headerCell => { document.querySelectorAll('th[data-sort]').forEach(headerCell => {
headerCell.addEventListener('click', () => { headerCell.addEventListener('click', () => {
@@ -174,6 +309,7 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
// Event listeners for pagination and filters
prevButton?.addEventListener('click', () => fetchHistory(currentPage - 1)); prevButton?.addEventListener('click', () => fetchHistory(currentPage - 1));
nextButton?.addEventListener('click', () => fetchHistory(currentPage + 1)); nextButton?.addEventListener('click', () => fetchHistory(currentPage + 1));
limitSelect?.addEventListener('change', (e) => { limitSelect?.addEventListener('change', (e) => {
@@ -182,6 +318,8 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
statusFilter?.addEventListener('change', () => fetchHistory(1)); statusFilter?.addEventListener('change', () => fetchHistory(1));
typeFilter?.addEventListener('change', () => fetchHistory(1)); typeFilter?.addEventListener('change', () => fetchHistory(1));
trackFilter?.addEventListener('change', () => fetchHistory(1));
hideChildTracksCheckbox?.addEventListener('change', () => fetchHistory(1));
// Initial fetch // Initial fetch
fetchHistory(); fetchHistory();

View File

@@ -46,35 +46,49 @@ interface ParentInfo {
} }
interface StatusData { interface StatusData {
type?: string; type?: 'track' | 'album' | 'playlist' | 'episode' | string;
status?: string; status?: 'initializing' | 'skipped' | 'retrying' | 'real-time' | 'error' | 'done' | 'processing' | 'queued' | 'progress' | 'track_progress' | 'complete' | 'cancelled' | 'cancel' | 'interrupted' | string;
name?: string;
song?: string; // --- Standardized Fields ---
music?: string;
title?: string;
artist?: string;
artist_name?: string;
album?: string;
owner?: string;
total_tracks?: number | string;
current_track?: number | string;
parsed_current_track?: string; // Make sure these are handled if they are strings
parsed_total_tracks?: string; // Make sure these are handled if they are strings
progress?: number | string; // Can be string initially
percentage?: number | string; // Can be string initially
percent?: number | string; // Can be string initially
time_elapsed?: number;
error?: string;
can_retry?: boolean;
retry_count?: number;
max_retries?: number; // from config potentially
seconds_left?: number;
task_id?: string;
url?: string; url?: string;
reason?: string; // for skipped convert_to?: string;
bitrate?: string;
// Item metadata
song?: string;
artist?: string;
album?: string;
title?: string; // for album
name?: string; // for playlist/track
owner?: string; // for playlist
parent?: ParentInfo; parent?: ParentInfo;
// Progress indicators
current_track?: number | string;
total_tracks?: number | string;
progress?: number | string; // 0-100
time_elapsed?: number; // ms
// Status-specific details
reason?: string; // for 'skipped'
error?: string; // for 'error', 'retrying'
retry_count?: number;
seconds_left?: number;
summary?: {
successful_tracks?: string[];
skipped_tracks?: string[];
failed_tracks?: { track: string; reason: string }[];
total_successful?: number;
total_skipped?: number;
total_failed?: number;
};
// --- Fields for internal FE logic or from API wrapper ---
task_id?: string;
can_retry?: boolean;
max_retries?: number; // from config
original_url?: string; original_url?: string;
position?: number; // For queued items position?: number;
original_request?: { original_request?: {
url?: string; url?: string;
retry_url?: string; retry_url?: string;
@@ -87,11 +101,12 @@ interface StatusData {
display_type?: string; display_type?: string;
display_artist?: string; display_artist?: string;
service?: string; service?: string;
[key: string]: any; // For other potential original_request params [key: string]: any;
}; };
event?: string; // from SSE event?: string;
overall_progress?: number; overall_progress?: number;
display_type?: string; // from PRG data display_type?: string;
[key: string]: any; // Allow other properties [key: string]: any; // Allow other properties
} }
@@ -229,26 +244,20 @@ export class DownloadQueue {
// Load initial config from the server. // Load initial config from the server.
await this.loadConfig(); await this.loadConfig();
// Override the server value with locally persisted queue visibility (if present). // Use localStorage for queue visibility
const storedVisible = localStorage.getItem("downloadQueueVisible"); const storedVisible = localStorage.getItem("downloadQueueVisible");
if (storedVisible !== null) { const isVisible = storedVisible === "true";
// Ensure config is not null before assigning
if (this.config) {
this.config.downloadQueueVisible = storedVisible === "true";
}
}
const queueSidebar = document.getElementById('downloadQueue'); const queueSidebar = document.getElementById('downloadQueue');
// Ensure config is not null and queueSidebar exists if (queueSidebar) {
if (this.config && queueSidebar) { queueSidebar.hidden = !isVisible;
queueSidebar.hidden = !this.config.downloadQueueVisible; queueSidebar.classList.toggle('active', isVisible);
queueSidebar.classList.toggle('active', !!this.config.downloadQueueVisible);
} }
// Initialize the queue icon based on sidebar visibility // Initialize the queue icon based on sidebar visibility
const queueIcon = document.getElementById('queueIcon'); const queueIcon = document.getElementById('queueIcon');
if (queueIcon && this.config) { if (queueIcon) {
if (this.config.downloadQueueVisible) { if (isVisible) {
queueIcon.innerHTML = '<img src="/static/images/cross.svg" alt="Close queue" class="queue-x">'; queueIcon.innerHTML = '<img src="/static/images/cross.svg" alt="Close queue" class="queue-x">';
queueIcon.setAttribute('aria-expanded', 'true'); queueIcon.setAttribute('aria-expanded', 'true');
queueIcon.classList.add('queue-icon-active'); // Add red tint class queueIcon.classList.add('queue-icon-active'); // Add red tint class
@@ -326,7 +335,7 @@ export class DownloadQueue {
// Update the queue icon to show X when visible or queue icon when hidden // Update the queue icon to show X when visible or queue icon when hidden
const queueIcon = document.getElementById('queueIcon'); const queueIcon = document.getElementById('queueIcon');
if (queueIcon && this.config) { if (queueIcon) {
if (isVisible) { if (isVisible) {
// Replace the image with an X and add red tint // Replace the image with an X and add red tint
queueIcon.innerHTML = '<img src="/static/images/cross.svg" alt="Close queue" class="queue-x">'; queueIcon.innerHTML = '<img src="/static/images/cross.svg" alt="Close queue" class="queue-x">';
@@ -340,34 +349,9 @@ export class DownloadQueue {
} }
} }
// Persist the state locally so it survives refreshes. // Only persist the state in localStorage, not on the server
localStorage.setItem("downloadQueueVisible", String(isVisible)); localStorage.setItem("downloadQueueVisible", String(isVisible));
this.dispatchEvent('queueVisibilityChanged', { visible: isVisible });
try {
await this.loadConfig();
const updatedConfig = { ...this.config, downloadQueueVisible: isVisible };
await this.saveConfig(updatedConfig);
this.dispatchEvent('queueVisibilityChanged', { visible: isVisible });
} catch (error) {
console.error('Failed to save queue visibility:', error);
// Revert UI if save failed.
queueSidebar.classList.toggle('active', !isVisible);
queueSidebar.hidden = isVisible;
// Also revert the icon back
if (queueIcon && this.config) {
if (!isVisible) {
queueIcon.innerHTML = '<img src="/static/images/cross.svg" alt="Close queue" class="queue-x">';
queueIcon.setAttribute('aria-expanded', 'true');
queueIcon.classList.add('queue-icon-active'); // Add red tint class
} else {
queueIcon.innerHTML = '<img src="/static/images/cross.svg" alt="Close queue" class="queue-x">';
queueIcon.setAttribute('aria-expanded', 'true');
queueIcon.classList.add('queue-icon-active'); // Add red tint class
}
}
this.dispatchEvent('queueVisibilityChanged', { visible: !isVisible });
this.showError('Failed to save queue visibility');
}
if (isVisible) { if (isVisible) {
// If the queue is now visible, ensure all visible items are being polled. // If the queue is now visible, ensure all visible items are being polled.
@@ -644,7 +628,7 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...'; const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...';
// Use display values if available, or fall back to standard fields // Use display values if available, or fall back to standard fields
const displayTitle = item.name || item.music || item.song || 'Unknown'; const displayTitle = item.name || item.song || 'Unknown';
const displayArtist = item.artist || ''; const displayArtist = item.artist || '';
const displayType = type.charAt(0).toUpperCase() + type.slice(1); const displayType = type.charAt(0).toUpperCase() + type.slice(1);
@@ -1037,9 +1021,9 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
} }
// Extract common fields // Extract common fields
const trackName = data.song || data.music || data.name || data.title || const trackName = data.song || data.name || data.title ||
(queueItem?.item?.name) || 'Unknown'; (queueItem?.item?.name) || 'Unknown';
const artist = data.artist || data.artist_name || const artist = data.artist ||
(queueItem?.item?.artist) || ''; (queueItem?.item?.artist) || '';
const albumTitle = data.title || data.album || data.parent?.title || data.name || const albumTitle = data.title || data.album || data.parent?.title || data.name ||
(queueItem?.item?.name) || ''; (queueItem?.item?.name) || '';
@@ -1047,18 +1031,14 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
(queueItem?.item?.name) || ''; (queueItem?.item?.name) || '';
const playlistOwner = data.owner || data.parent?.owner || const playlistOwner = data.owner || data.parent?.owner ||
(queueItem?.item?.owner) || ''; // Add type check if item.owner is object (queueItem?.item?.owner) || ''; // Add type check if item.owner is object
const currentTrack = data.current_track || data.parsed_current_track || ''; const currentTrack = data.current_track || '';
const totalTracks = data.total_tracks || data.parsed_total_tracks || data.parent?.total_tracks || const totalTracks = data.total_tracks || data.parent?.total_tracks ||
(queueItem?.item?.total_tracks) || ''; (queueItem?.item?.total_tracks) || '';
// Format percentage for display when available // Format percentage for display when available
let formattedPercentage = '0'; let formattedPercentage = '0';
if (data.progress !== undefined) { if (data.progress !== undefined) {
formattedPercentage = parseFloat(data.progress as string).toFixed(1); // Cast to string formattedPercentage = Number(data.progress).toFixed(1);
} else if (data.percentage) {
formattedPercentage = (parseFloat(data.percentage as string) * 100).toFixed(1); // Cast to string
} else if (data.percent) {
formattedPercentage = (parseFloat(data.percent as string) * 100).toFixed(1); // Cast to string
} }
// Helper for constructing info about the parent item // Helper for constructing info about the parent item
@@ -1204,11 +1184,37 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
case 'done': case 'done':
case 'complete': case 'complete':
if (data.type === 'track') { // Final summary for album/playlist
return `Downloaded "${trackName}"${artist ? ` by ${artist}` : ''} successfully${getParentInfo()}`; if (data.summary && (data.type === 'album' || data.type === 'playlist')) {
} else if (data.type === 'album') { const { total_successful = 0, total_skipped = 0, total_failed = 0, failed_tracks = [] } = data.summary;
const name = data.type === 'album' ? (data.title || albumTitle) : (data.name || playlistName);
return `Finished ${data.type} "${name}". Success: ${total_successful}, Skipped: ${total_skipped}, Failed: ${total_failed}.`;
}
// Final status for a single track (without a parent)
if (data.type === 'track' && !data.parent) {
return `Downloaded "${trackName}"${artist ? ` by ${artist}` : ''} successfully`;
}
// A 'done' status for a track *within* a parent collection is just an intermediate step.
if (data.type === 'track' && data.parent) {
const parentType = data.parent.type === 'album' ? 'album' : 'playlist';
const parentName = data.parent.type === 'album' ? (data.parent.title || '') : (data.parent.name || '');
const nextTrack = Number(data.current_track || 0) + 1;
const totalTracks = Number(data.total_tracks || 0);
if (nextTrack > totalTracks) {
return `Finalizing ${parentType} "${parentName}"... (${data.current_track}/${totalTracks} tracks completed)`;
} else {
return `Completed track ${data.current_track}/${totalTracks}: "${trackName}" by ${artist}. Preparing next track...`;
}
}
// Fallback for album/playlist without summary
if (data.type === 'album') {
return `Downloaded album "${albumTitle}"${artist ? ` by ${artist}` : ''} successfully (${totalTracks} tracks)`; return `Downloaded album "${albumTitle}"${artist ? ` by ${artist}` : ''} successfully (${totalTracks} tracks)`;
} else if (data.type === 'playlist') { }
if (data.type === 'playlist') {
return `Downloaded playlist "${playlistName}"${playlistOwner ? ` by ${playlistOwner}` : ''} successfully (${totalTracks} tracks)`; return `Downloaded playlist "${playlistName}"${playlistOwner ? ` by ${playlistOwner}` : ''} successfully (${totalTracks} tracks)`;
} }
return `Downloaded ${data.type} successfully`; return `Downloaded ${data.type} successfully`;
@@ -1276,6 +1282,12 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
/* New Methods to Handle Terminal State, Inactivity and Auto-Retry */ /* New Methods to Handle Terminal State, Inactivity and Auto-Retry */
handleDownloadCompletion(entry: QueueEntry, queueId: string, progress: StatusData | number) { // Add types handleDownloadCompletion(entry: QueueEntry, queueId: string, progress: StatusData | number) { // Add types
// SAFETY CHECK: Never mark a track with a parent as completed
if (typeof progress !== 'number' && progress.type === 'track' && progress.parent) {
console.log(`Prevented completion of track ${progress.song} that is part of ${progress.parent.type}`);
return; // Exit early and don't mark as complete
}
// Mark the entry as ended // Mark the entry as ended
entry.hasEnded = true; entry.hasEnded = true;
@@ -1292,10 +1304,11 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Stop polling // Stop polling
this.clearPollingInterval(queueId); this.clearPollingInterval(queueId);
// Use 3 seconds cleanup delay for completed, 10 seconds for other terminal states like errors // Use 3 seconds cleanup delay for completed, 10 seconds for errors, and 20 seconds for cancelled/skipped
const cleanupDelay = (progress && typeof progress !== 'number' && (progress.status === 'complete' || progress.status === 'done')) ? 3000 : const cleanupDelay = (progress && typeof progress !== 'number' && (progress.status === 'complete' || progress.status === 'done')) ? 3000 :
(progress && typeof progress !== 'number' && progress.status === 'error') ? 10000 :
(progress && typeof progress !== 'number' && (progress.status === 'cancelled' || progress.status === 'cancel' || progress.status === 'skipped')) ? 20000 : (progress && typeof progress !== 'number' && (progress.status === 'cancelled' || progress.status === 'cancel' || progress.status === 'skipped')) ? 20000 :
10000; // Default for other errors if not caught by the more specific error handler delay 10000; // Default for other cases if not caught by the more specific conditions
// Clean up after the appropriate delay // Clean up after the appropriate delay
setTimeout(() => { setTimeout(() => {
@@ -1655,7 +1668,7 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
if (this.queueCache[taskId]) { if (this.queueCache[taskId]) {
delete this.queueCache[taskId]; delete this.queueCache[taskId];
} }
continue; continue; // Skip adding terminal tasks to UI if not already there
} }
let itemType = taskData.type || originalRequest.type || 'unknown'; let itemType = taskData.type || originalRequest.type || 'unknown';
@@ -1753,7 +1766,6 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
} catch (error) { } catch (error) {
console.error('Error loading config:', error); console.error('Error loading config:', error);
this.config = { // Initialize with a default structure on error this.config = { // Initialize with a default structure on error
downloadQueueVisible: false,
maxRetries: 3, maxRetries: 3,
retryDelaySeconds: 5, retryDelaySeconds: 5,
retry_delay_increase: 5, retry_delay_increase: 5,
@@ -1762,21 +1774,6 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
} }
} }
async saveConfig(updatedConfig: AppConfig) { // Add type
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedConfig)
});
if (!response.ok) throw new Error('Failed to save config');
this.config = await response.json();
} catch (error) {
console.error('Error saving config:', error);
throw error;
}
}
// Add a method to check if explicit filter is enabled // Add a method to check if explicit filter is enabled
isExplicitFilterEnabled(): boolean { // Add return type isExplicitFilterEnabled(): boolean { // Add return type
return !!this.config.explicitFilter; return !!this.config.explicitFilter;
@@ -1889,6 +1886,15 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Handle terminal states // Handle terminal states
if (data.last_line && ['complete', 'error', 'cancelled', 'done'].includes(data.last_line.status || '')) { // Add null check if (data.last_line && ['complete', 'error', 'cancelled', 'done'].includes(data.last_line.status || '')) { // Add null check
console.log(`Terminal state detected: ${data.last_line.status} for ${queueId}`); console.log(`Terminal state detected: ${data.last_line.status} for ${queueId}`);
// SAFETY CHECK: Don't mark track as ended if it has a parent
if (data.last_line.type === 'track' && data.last_line.parent) {
console.log(`Not marking track ${data.last_line.song} as ended because it has a parent ${data.last_line.parent.type}`);
// Still update the UI
this.handleStatusUpdate(queueId, data);
return;
}
entry.hasEnded = true; entry.hasEnded = true;
// For cancelled downloads, clean up immediately // For cancelled downloads, clean up immediately
@@ -1953,22 +1959,42 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Extract the actual status data from the API response // Extract the actual status data from the API response
const statusData: StatusData = data.last_line || {}; // Add type const statusData: StatusData = data.last_line || {}; // Add type
// Special handling for track status updates that are part of an album/playlist // --- Normalize statusData to conform to expected types ---
// We want to keep these for showing the track-by-track progress const numericFields = ['current_track', 'total_tracks', 'progress', 'retry_count', 'seconds_left', 'time_elapsed'];
if (statusData.type === 'track' && statusData.parent) { for (const field of numericFields) {
// If this is a track that's part of our album/playlist, keep it if (statusData[field] !== undefined && typeof statusData[field] === 'string') {
if ((entry.type === 'album' && statusData.parent.type === 'album') || statusData[field] = parseFloat(statusData[field] as string);
(entry.type === 'playlist' && statusData.parent.type === 'playlist')) { }
console.log(`Processing track status update for ${entry.type}: ${statusData.song}`);
}
} }
// Only skip updates where type doesn't match AND there's no relevant parent relationship
else if (statusData.type && entry.type && statusData.type !== entry.type && const entryType = entry.type;
(!statusData.parent || statusData.parent.type !== entry.type)) { const updateType = statusData.type;
console.log(`Skipping mismatched type: update=${statusData.type}, entry=${entry.type}`);
return; if (!updateType) {
console.warn("Status update received without a 'type'. Ignoring.", statusData);
return;
} }
// --- Filtering logic based on download type ---
// A status update is relevant if its type matches the queue entry's type,
// OR if it's a 'track' update that belongs to an 'album' or 'playlist' entry.
let isRelevantUpdate = false;
if (updateType === entryType) {
isRelevantUpdate = true;
} else if (updateType === 'track' && statusData.parent) {
if (entryType === 'album' && statusData.parent.type === 'album') {
isRelevantUpdate = true;
} else if (entryType === 'playlist' && statusData.parent.type === 'playlist') {
isRelevantUpdate = true;
}
}
if (!isRelevantUpdate) {
console.log(`Skipping status update with type '${updateType}' for entry of type '${entryType}'.`, statusData);
return;
}
// Get primary status // Get primary status
let status = statusData.status || data.event || 'unknown'; // Define status *before* potential modification let status = statusData.status || data.event || 'unknown'; // Define status *before* potential modification
@@ -2062,6 +2088,32 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Apply appropriate status classes // Apply appropriate status classes
this.applyStatusClasses(entry, statusData); // Pass statusData instead of status string this.applyStatusClasses(entry, statusData); // Pass statusData instead of status string
if (status === 'done' || status === 'complete') {
if (statusData.summary && (entry.type === 'album' || entry.type === 'playlist')) {
const { total_successful = 0, total_skipped = 0, total_failed = 0, failed_tracks = [] } = statusData.summary;
const summaryDiv = document.createElement('div');
summaryDiv.className = 'download-summary';
let summaryHTML = `
<div class="summary-line">
<strong>Finished:</strong>
<span><img src="/static/images/check.svg" alt="Success" class="summary-icon"> ${total_successful}</span>
<span><img src="/static/images/skip.svg" alt="Skipped" class="summary-icon"> ${total_skipped}</span>
<span><img src="/static/images/cross.svg" alt="Failed" class="summary-icon"> ${total_failed}</span>
</div>
`;
// Remove the individual failed tracks list
// The user only wants to see the count, not the names
summaryDiv.innerHTML = summaryHTML;
if (logElement) {
logElement.innerHTML = ''; // Clear previous message
logElement.appendChild(summaryDiv);
}
}
}
// Special handling for error status based on new API response format // Special handling for error status based on new API response format
if (status === 'error') { if (status === 'error') {
entry.hasEnded = true; entry.hasEnded = true;
@@ -2146,9 +2198,23 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
} }
// Handle terminal states for non-error cases // Handle terminal states for non-error cases
if (['complete', 'cancel', 'cancelled', 'done', 'skipped'].includes(status)) { if (['complete', 'done', 'skipped', 'cancelled', 'cancel'].includes(status)) {
entry.hasEnded = true; // Only mark as ended if the update type matches the entry type.
this.handleDownloadCompletion(entry, queueId, statusData); // e.g., an album download is only 'done' when an 'album' status says so,
// not when an individual 'track' within it is 'done'.
if (statusData.type === entry.type) {
entry.hasEnded = true;
this.handleDownloadCompletion(entry, queueId, statusData);
}
// IMPORTANT: Never mark a track as ended if it has a parent
else if (statusData.type === 'track' && statusData.parent) {
console.log(`Track ${statusData.song} in ${statusData.parent.type} has completed, but not ending the parent download.`);
// Update UI but don't trigger completion
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
if (logElement) {
logElement.textContent = this.getStatusMessage(statusData);
}
}
} }
// Cache the status for potential page reloads // Cache the status for potential page reloads
@@ -2211,7 +2277,7 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
if (trackProgressBar && statusData.progress !== undefined) { if (trackProgressBar && statusData.progress !== undefined) {
// Update track progress bar // Update track progress bar
const progress = parseFloat(statusData.progress as string); // Cast to string const progress = Number(statusData.progress);
trackProgressBar.style.width = `${progress}%`; trackProgressBar.style.width = `${progress}%`;
trackProgressBar.setAttribute('aria-valuenow', progress.toString()); // Use string trackProgressBar.setAttribute('aria-valuenow', progress.toString()); // Use string
@@ -2321,11 +2387,7 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Real-time progress for direct track download // Real-time progress for direct track download
if (statusData.status === 'real-time' && statusData.progress !== undefined) { if (statusData.status === 'real-time' && statusData.progress !== undefined) {
progress = parseFloat(statusData.progress as string); // Cast to string progress = Number(statusData.progress);
} else if (statusData.percent !== undefined) {
progress = parseFloat(statusData.percent as string) * 100; // Cast to string
} else if (statusData.percentage !== undefined) {
progress = parseFloat(statusData.percentage as string) * 100; // Cast to string
} else if (statusData.status === 'done' || statusData.status === 'complete') { } else if (statusData.status === 'done' || statusData.status === 'complete') {
progress = 100; progress = 100;
} else if (statusData.current_track && statusData.total_tracks) { } else if (statusData.current_track && statusData.total_tracks) {
@@ -2372,6 +2434,44 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
let totalTracks = 0; let totalTracks = 0;
let trackProgress = 0; let trackProgress = 0;
// SPECIAL CASE: If this is the final 'done' status for the entire album/playlist (not a track)
if ((statusData.status === 'done' || statusData.status === 'complete') &&
(statusData.type === 'album' || statusData.type === 'playlist') &&
statusData.type === entry.type &&
statusData.total_tracks) {
console.log('Final album/playlist completion. Setting progress to 100%');
// Extract total tracks
totalTracks = parseInt(String(statusData.total_tracks), 10);
// Force current track to equal total tracks for completion
currentTrack = totalTracks;
// Update counter to show n/n
if (progressCounter) {
progressCounter.textContent = `${totalTracks}/${totalTracks}`;
}
// Set progress bar to 100%
if (overallProgressBar) {
overallProgressBar.style.width = '100%';
overallProgressBar.setAttribute('aria-valuenow', '100');
overallProgressBar.classList.add('complete');
}
// Hide track progress or set to complete
if (trackProgressBar) {
const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null;
if (trackProgressContainer) {
trackProgressContainer.style.display = 'none'; // Optionally hide or set to 100%
}
}
// Store for later use
entry.progress = 100;
return;
}
// Handle track-level updates for album/playlist downloads // Handle track-level updates for album/playlist downloads
if (statusData.type === 'track' && statusData.parent && if (statusData.type === 'track' && statusData.parent &&
(entry.type === 'album' || entry.type === 'playlist')) { (entry.type === 'album' || entry.type === 'playlist')) {
@@ -2399,6 +2499,12 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Get current track and total tracks from the status data // Get current track and total tracks from the status data
if (statusData.current_track !== undefined) { if (statusData.current_track !== undefined) {
currentTrack = parseInt(String(statusData.current_track), 10); currentTrack = parseInt(String(statusData.current_track), 10);
// For completed tracks, use the track number rather than one less
if (statusData.status === 'done' || statusData.status === 'complete') {
// The current track is the one that just completed
currentTrack = parseInt(String(statusData.current_track), 10);
}
// Get total tracks - try from statusData first, then from parent // Get total tracks - try from statusData first, then from parent
if (statusData.total_tracks !== undefined) { if (statusData.total_tracks !== undefined) {
@@ -2412,7 +2518,10 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Get track progress for real-time updates // Get track progress for real-time updates
if (statusData.status === 'real-time' && statusData.progress !== undefined) { if (statusData.status === 'real-time' && statusData.progress !== undefined) {
trackProgress = parseFloat(statusData.progress as string); // Cast to string trackProgress = Number(statusData.progress); // Cast to number
} else if (statusData.status === 'done' || statusData.status === 'complete') {
// For a completed track, set trackProgress to 100%
trackProgress = 100;
} }
// Update the track progress counter display // Update the track progress counter display
@@ -2424,7 +2533,9 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
if (logElement && statusData.song && statusData.artist) { if (logElement && statusData.song && statusData.artist) {
let progressInfo = ''; let progressInfo = '';
if (statusData.status === 'real-time' && trackProgress > 0) { if (statusData.status === 'real-time' && trackProgress > 0) {
progressInfo = ` - ${trackProgress.toFixed(1)}%`; progressInfo = ` - ${trackProgress}%`;
} else if (statusData.status === 'done' || statusData.status === 'complete') {
progressInfo = ' - Complete';
} }
logElement.textContent = `Currently downloading: ${statusData.song} by ${statusData.artist} (${currentTrack}/${totalTracks}${progressInfo})`; logElement.textContent = `Currently downloading: ${statusData.song} by ${statusData.artist} (${currentTrack}/${totalTracks}${progressInfo})`;
} }
@@ -2432,16 +2543,23 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Calculate and update the overall progress bar // Calculate and update the overall progress bar
if (totalTracks > 0) { if (totalTracks > 0) {
let overallProgress = 0; let overallProgress = 0;
// Always compute overall based on trackProgress if available, using album/playlist real-time formula
if (trackProgress !== undefined) { // For completed tracks, use completed/total
if (statusData.status === 'done' || statusData.status === 'complete') {
// For completed tracks, this track is fully complete
overallProgress = (currentTrack / totalTracks) * 100;
}
// For in-progress tracks, use the real-time formula
else if (trackProgress !== undefined) {
const completedTracksProgress = (currentTrack - 1) / totalTracks; const completedTracksProgress = (currentTrack - 1) / totalTracks;
const currentTrackContribution = (1 / totalTracks) * (trackProgress / 100); const currentTrackContribution = (1 / totalTracks) * (trackProgress / 100);
overallProgress = (completedTracksProgress + currentTrackContribution) * 100; overallProgress = (completedTracksProgress + currentTrackContribution) * 100;
console.log(`Overall progress: ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks}, Progress: ${trackProgress}%)`);
} else { } else {
// Fallback to track count method
overallProgress = (currentTrack / totalTracks) * 100; overallProgress = (currentTrack / totalTracks) * 100;
console.log(`Overall progress (non-real-time): ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks})`);
} }
console.log(`Overall progress: ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks}, Progress: ${trackProgress}%)`);
// Update the progress bar // Update the progress bar
if (overallProgressBar) { if (overallProgressBar) {
@@ -2464,23 +2582,36 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
trackProgressContainer.style.display = 'block'; trackProgressContainer.style.display = 'block';
} }
if (statusData.status === 'real-time') { if (statusData.status === 'real-time' || statusData.status === 'real_time') {
// Real-time progress for the current track // For real-time updates, use the track progress for the small green progress bar
const safeTrackProgress = Math.max(0, Math.min(100, trackProgress)); // This shows download progress for the current track only
trackProgressBar.style.width = `${safeTrackProgress}%`; const safeProgress = isNaN(trackProgress) ? 0 : Math.max(0, Math.min(100, trackProgress));
trackProgressBar.setAttribute('aria-valuenow', safeTrackProgress.toString()); // Use string trackProgressBar.style.width = `${safeProgress}%`;
trackProgressBar.setAttribute('aria-valuenow', String(safeProgress));
trackProgressBar.classList.add('real-time'); trackProgressBar.classList.add('real-time');
if (safeTrackProgress >= 100) { if (safeProgress >= 100) {
trackProgressBar.classList.add('complete'); trackProgressBar.classList.add('complete');
} else { } else {
trackProgressBar.classList.remove('complete'); trackProgressBar.classList.remove('complete');
} }
} else { } else if (statusData.status === 'done' || statusData.status === 'complete') {
// Indeterminate progress animation for non-real-time updates // For completed tracks, show 100%
trackProgressBar.style.width = '100%';
trackProgressBar.setAttribute('aria-valuenow', '100');
trackProgressBar.classList.add('complete');
} else if (['progress', 'processing'].includes(statusData.status || '')) {
// For non-real-time progress updates, show an indeterminate-style progress
// by using a pulsing animation via CSS
trackProgressBar.classList.add('progress-pulse'); trackProgressBar.classList.add('progress-pulse');
trackProgressBar.style.width = '100%'; trackProgressBar.style.width = '100%';
trackProgressBar.setAttribute('aria-valuenow', "50"); // Use string trackProgressBar.setAttribute('aria-valuenow', String(50)); // indicate in-progress
} else {
// For other status updates, use current track position
trackProgressBar.classList.remove('progress-pulse');
const trackPositionPercent = currentTrack > 0 ? 100 : 0;
trackProgressBar.style.width = `${trackPositionPercent}%`;
trackProgressBar.setAttribute('aria-valuenow', String(trackPositionPercent));
} }
} }
@@ -2523,18 +2654,21 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
totalTracks = parseInt(parts[1], 10); totalTracks = parseInt(parts[1], 10);
} }
// For completed albums/playlists, ensure current track equals total tracks
if ((statusData.status === 'done' || statusData.status === 'complete') &&
(statusData.type === 'album' || statusData.type === 'playlist') &&
statusData.type === entry.type &&
totalTracks > 0) {
currentTrack = totalTracks;
}
// Get track progress for real-time downloads // Get track progress for real-time downloads
if (statusData.status === 'real-time' && statusData.progress !== undefined) { if (statusData.status === 'real-time' && statusData.progress !== undefined) {
// For real-time downloads, progress comes as a percentage value (0-100) // For real-time downloads, progress comes as a percentage value (0-100)
trackProgress = parseFloat(statusData.progress as string); // Cast to string trackProgress = Number(statusData.progress); // Cast to number
} else if (statusData.percent !== undefined) {
// Handle percent values (0-1)
trackProgress = parseFloat(statusData.percent as string) * 100; // Cast to string
} else if (statusData.percentage !== undefined) {
// Handle percentage values (0-1)
trackProgress = parseFloat(statusData.percentage as string) * 100; // Cast to string
} else if (statusData.status === 'done' || statusData.status === 'complete') { } else if (statusData.status === 'done' || statusData.status === 'complete') {
progress = 100; progress = 100;
trackProgress = 100; // Also set trackProgress to 100% for completed status
} else if (statusData.current_track && statusData.total_tracks) { } else if (statusData.current_track && statusData.total_tracks) {
// If we don't have real-time progress but do have track position // If we don't have real-time progress but do have track position
progress = (parseInt(statusData.current_track as string, 10) / parseInt(statusData.total_tracks as string, 10)) * 100; // Cast to string progress = (parseInt(statusData.current_track as string, 10) / parseInt(statusData.total_tracks as string, 10)) * 100; // Cast to string
@@ -2730,6 +2864,18 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
const serverEquivalent = serverTasks.find(st => st.task_id === localEntry.taskId); const serverEquivalent = serverTasks.find(st => st.task_id === localEntry.taskId);
if (serverEquivalent && serverEquivalent.last_status_obj && terminalStates.includes(serverEquivalent.last_status_obj.status)) { if (serverEquivalent && serverEquivalent.last_status_obj && terminalStates.includes(serverEquivalent.last_status_obj.status)) {
if (!localEntry.hasEnded) { if (!localEntry.hasEnded) {
// Don't clean up if this is a track with a parent
if (serverEquivalent.last_status_obj.type === 'track' && serverEquivalent.last_status_obj.parent) {
console.log(`Periodic sync: Not cleaning up track ${serverEquivalent.last_status_obj.song} with parent ${serverEquivalent.last_status_obj.parent.type}`);
continue;
}
// Only clean up if the types match (e.g., don't clean up an album when a track is done)
if (serverEquivalent.last_status_obj.type !== localEntry.type) {
console.log(`Periodic sync: Not cleaning up ${localEntry.type} entry due to ${serverEquivalent.last_status_obj.type} status update`);
continue;
}
console.log(`Periodic sync: Local task ${localEntry.taskId} is now terminal on server (${serverEquivalent.last_status_obj.status}). Cleaning up.`); console.log(`Periodic sync: Local task ${localEntry.taskId} is now terminal on server (${serverEquivalent.last_status_obj.status}). Cleaning up.`);
this.handleDownloadCompletion(localEntry, localEntry.uniqueId, serverEquivalent.last_status_obj); this.handleDownloadCompletion(localEntry, localEntry.uniqueId, serverEquivalent.last_status_obj);
} }

View File

@@ -38,6 +38,71 @@ tr:nth-child(even) {
background-color: #222; background-color: #222;
} }
/* Parent and child track styling */
.parent-task-row {
background-color: #282828 !important;
font-weight: bold;
}
.child-track-row {
background-color: #1a1a1a !important;
font-size: 0.9em;
}
.child-track-indent {
color: #1DB954;
margin-right: 5px;
}
/* Track status styling */
.track-status-successful {
color: #1DB954;
font-weight: bold;
}
.track-status-skipped {
color: #FFD700;
font-weight: bold;
}
.track-status-failed {
color: #FF4136;
font-weight: bold;
}
/* Track counts display */
.track-counts {
margin-left: 10px;
font-size: 0.85em;
}
.track-count.success {
color: #1DB954;
}
.track-count.skipped {
color: #FFD700;
}
.track-count.failed {
color: #FF4136;
}
/* Back button */
#back-to-history {
margin-right: 15px;
padding: 5px 10px;
background-color: #333;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
#back-to-history:hover {
background-color: #444;
}
.pagination { .pagination {
margin-top: 20px; margin-top: 20px;
text-align: center; text-align: center;
@@ -63,6 +128,7 @@ tr:nth-child(even) {
display: flex; display: flex;
gap: 15px; gap: 15px;
align-items: center; align-items: center;
flex-wrap: wrap;
} }
.filters label, .filters select, .filters input { .filters label, .filters select, .filters input {
@@ -77,9 +143,16 @@ tr:nth-child(even) {
border-radius: 4px; border-radius: 4px;
} }
.checkbox-filter {
display: flex;
align-items: center;
gap: 5px;
}
.status-COMPLETED { color: #1DB954; font-weight: bold; } .status-COMPLETED { color: #1DB954; font-weight: bold; }
.status-ERROR { color: #FF4136; font-weight: bold; } .status-ERROR { color: #FF4136; font-weight: bold; }
.status-CANCELLED { color: #AAAAAA; } .status-CANCELLED { color: #AAAAAA; }
.status-skipped { color: #FFD700; font-weight: bold; }
.error-message-toggle { .error-message-toggle {
cursor: pointer; cursor: pointer;
@@ -97,8 +170,8 @@ tr:nth-child(even) {
font-size: 0.9em; font-size: 0.9em;
} }
/* Styling for the Details icon button in the table */ /* Styling for the buttons in the table */
.details-btn { .btn-icon {
background-color: transparent; /* Or a subtle color like #282828 */ background-color: transparent; /* Or a subtle color like #282828 */
border: none; border: none;
border-radius: 50%; /* Make it circular */ border-radius: 50%; /* Make it circular */
@@ -108,14 +181,23 @@ tr:nth-child(even) {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
margin-right: 5px;
} }
.details-btn img { .btn-icon img {
width: 16px; /* Icon size */ width: 16px; /* Icon size */
height: 16px; height: 16px;
filter: invert(1); /* Make icon white if it's dark, adjust if needed */ filter: invert(1); /* Make icon white if it's dark, adjust if needed */
} }
.details-btn:hover { .btn-icon:hover {
background-color: #333; /* Darker on hover */ background-color: #333; /* Darker on hover */
}
.details-btn:hover img {
filter: invert(0.8) sepia(1) saturate(5) hue-rotate(175deg); /* Make icon blue on hover */
}
.tracks-btn:hover img {
filter: invert(0.8) sepia(1) saturate(5) hue-rotate(90deg); /* Make icon green on hover */
} }

View File

@@ -573,6 +573,85 @@
margin-top: 8px; margin-top: 8px;
} }
/* ----------------------------- */
/* DOWNLOAD SUMMARY ICONS */
/* ----------------------------- */
/* Base styles for all summary icons */
.summary-icon {
width: 14px;
height: 14px;
vertical-align: middle;
margin-right: 4px;
margin-top: -2px;
}
/* Download summary formatting */
.download-summary {
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
padding: 12px;
margin-top: 5px;
}
.summary-line {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.summary-line span {
display: flex;
align-items: center;
padding: 3px 8px;
border-radius: 4px;
font-weight: 500;
}
/* Specific icon background colors */
.summary-line span:nth-child(2) {
background: rgba(29, 185, 84, 0.1); /* Success background */
}
.summary-line span:nth-child(3) {
background: rgba(230, 126, 34, 0.1); /* Skip background */
}
.summary-line span:nth-child(4) {
background: rgba(255, 85, 85, 0.1); /* Failed background */
}
/* Failed tracks list styling */
.failed-tracks-title {
color: #ff5555;
font-weight: 600;
margin: 10px 0 5px;
font-size: 13px;
}
.failed-tracks-list {
list-style-type: none;
padding-left: 10px;
margin: 0;
font-size: 12px;
color: #b3b3b3;
max-height: 100px;
overflow-y: auto;
}
.failed-tracks-list li {
padding: 3px 0;
position: relative;
}
.failed-tracks-list li::before {
content: "•";
color: #ff5555;
position: absolute;
left: -10px;
}
/* Base styles for error buttons */ /* Base styles for error buttons */
.error-buttons button { .error-buttons button {
border: none; border: none;

View File

@@ -19,7 +19,7 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>Download History</h1> <h1 id="history-title">Download History</h1>
<div class="filters"> <div class="filters">
<label for="status-filter">Status:</label> <label for="status-filter">Status:</label>
@@ -38,6 +38,19 @@
<option value="playlist">Playlist</option> <option value="playlist">Playlist</option>
<option value="artist">Artist</option> <option value="artist">Artist</option>
</select> </select>
<label for="track-filter">Track Status:</label>
<select id="track-filter">
<option value="">All</option>
<option value="SUCCESSFUL">Successful</option>
<option value="SKIPPED">Skipped</option>
<option value="FAILED">Failed</option>
</select>
<div class="checkbox-filter">
<input type="checkbox" id="hide-child-tracks" />
<label for="hide-child-tracks">Hide Individual Tracks</label>
</div>
</div> </div>
<table> <table>
@@ -45,13 +58,13 @@
<tr> <tr>
<th data-sort="item_name">Name</th> <th data-sort="item_name">Name</th>
<th data-sort="item_artist">Artist</th> <th data-sort="item_artist">Artist</th>
<th data-sort="download_type">Type</th> <th data-sort="download_type">Type/Status</th>
<th data-sort="service_used">Service</th> <th data-sort="service_used">Service</th>
<th data-sort="quality_profile">Quality</th> <th data-sort="quality_profile">Quality</th>
<th data-sort="status_final">Status</th> <th data-sort="status_final">Status</th>
<th data-sort="timestamp_added">Date Added</th> <th data-sort="timestamp_added">Date Added</th>
<th data-sort="timestamp_completed">Date Completed/Ended</th> <th data-sort="timestamp_completed">Date Completed/Ended</th>
<th>Details</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="history-table-body"> <tbody id="history-table-body">

3
static/images/list.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="#ffffff" d="M2 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H2zm0 1h12v12H2V2zm2 2v1h8V4H4zm0 3v1h8V7H4zm0 3v1h8v-1H4z"/>
</svg>

After

Width:  |  Height:  |  Size: 247 B

4
static/images/skip.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 6V18M13.5239 12.7809L8.6247 16.7002C7.96993 17.2241 7 16.7579 7 15.9194V8.08062C7 7.24212 7.96993 6.77595 8.6247 7.29976L13.5239 11.2191C14.0243 11.6195 14.0243 12.3805 13.5239 12.7809Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 513 B