2.5.0
This commit is contained in:
Xoconoch
2025-06-09 18:18:52 -06:00
committed by GitHub
20 changed files with 1244 additions and 455 deletions

View File

@@ -2,4 +2,4 @@ waitress==3.0.2
celery==5.5.3
Flask==3.1.1
flask_cors==6.0.0
deezspot-spotizerr==1.7.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_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 = {}
# Status filter
status_filter = request.args.get("status_final")
if status_filter:
filters["status_final"] = status_filter
# Download type filter
type_filter = request.args.get("download_type")
if type_filter:
filters["download_type"] = type_filter
# Add more filters as needed, e.g., by item_name (would need LIKE for partial match)
# search_term = request.args.get('search')
# if search_term:
# filters['item_name'] = f'%{search_term}%' # This would require LIKE in get_history_entries
# Parent task filter
parent_task_filter = request.args.get("parent_task_id")
if parent_task_filter:
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(
limit, offset, sort_by, sort_order, filters
@@ -45,3 +63,34 @@ def get_download_history():
except Exception as e:
logger.error(f"Error in /api/history endpoint: {e}", exc_info=True)
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)
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 = {
"original_url": dynamic_original_url,
"last_line": last_status,
"last_line": last_line_content,
"timestamp": time.time(),
"task_id": task_id,
"status_count": status_count,
}
if last_status and last_status.get("summary"):
response["summary"] = last_status["summary"]
return jsonify(response)
@@ -122,33 +130,34 @@ def list_tasks():
last_status = get_last_task_status(task_id)
if task_info and last_status:
detailed_tasks.append(
{
"task_id": task_id,
"type": task_info.get(
"type", task_summary.get("type", "unknown")
),
"name": task_info.get(
"name", task_summary.get("name", "Unknown")
),
"artist": task_info.get(
"artist", task_summary.get("artist", "")
),
"download_type": task_info.get(
"download_type",
task_summary.get("download_type", "unknown"),
),
"status": last_status.get(
"status", "unknown"
), # Keep summary status for quick access
"last_status_obj": last_status, # Full last status object
"original_request": task_info.get("original_request", {}),
"created_at": task_info.get("created_at", 0),
"timestamp": last_status.get(
"timestamp", task_info.get("created_at", 0)
),
}
)
task_details = {
"task_id": task_id,
"type": task_info.get(
"type", task_summary.get("type", "unknown")
),
"name": task_info.get(
"name", task_summary.get("name", "Unknown")
),
"artist": task_info.get(
"artist", task_summary.get("artist", "")
),
"download_type": task_info.get(
"download_type",
task_summary.get("download_type", "unknown"),
),
"status": last_status.get(
"status", "unknown"
), # Keep summary status for quick access
"last_status_obj": last_status, # Full last status object
"original_request": task_info.get("original_request", {}),
"created_at": task_info.get("created_at", 0),
"timestamp": last_status.get(
"timestamp", task_info.get("created_at", 0)
),
}
if last_status.get("summary"):
task_details["summary"] = last_status["summary"]
detailed_tasks.append(task_details)
elif (
task_info
): # If last_status is somehow missing, still provide some info

View File

@@ -127,6 +127,7 @@ class CeleryDownloadQueueManager:
NON_BLOCKING_STATES = [
ProgressState.COMPLETE,
ProgressState.DONE,
ProgressState.CANCELLED,
ProgressState.ERROR,
ProgressState.ERROR_RETRIED,
@@ -354,7 +355,11 @@ class CeleryDownloadQueueManager:
status = task.get("status")
# 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)
if result.get("status") == "cancelled":
cancelled_count += 1

View File

@@ -29,7 +29,7 @@ from routes.utils.watch.db import (
)
# 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
import redis
@@ -238,6 +238,9 @@ def _log_task_to_history(task_id, final_status_str, error_msg=None):
except Exception:
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 = {
"task_id": task_id,
"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
if bitrate_str
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)
# 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:
logger.error(
f"History: Error preparing or logging history for task {task_id}: {e}",
exc_info=True,
)
# --- End History Logging Helper ---
@@ -366,8 +388,8 @@ def retry_task(task_id):
# Update service settings
if service == "spotify":
if fallback_enabled:
task_info["main"] = config_params.get("deezer", "")
task_info["fallback"] = config_params.get("spotify", "")
task_info["main"] = config_params.get("spotify", "")
task_info["fallback"] = config_params.get("deezer", "")
task_info["quality"] = config_params.get("deezerQuality", "MP3_128")
task_info["fall_quality"] = config_params.get(
"spotifyQuality", "NORMAL"
@@ -536,6 +558,9 @@ class ProgressTrackingTask(Task):
Args:
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
# Ensure ./logs/tasks directory exists
@@ -570,9 +595,6 @@ class ProgressTrackingTask(Task):
# Get status type
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
task_info = get_task_info(task_id)
@@ -585,44 +607,47 @@ class ProgressTrackingTask(Task):
# Process based on status type using a more streamlined approach
if status == "initializing":
# --- 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":
# --- 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":
# --- 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":
# --- 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":
# --- 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":
# --- 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":
# --- 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":
# --- DONE: Download operation completed ---
self._handle_done(task_id, stored_data, task_info)
self._handle_done(task_id, progress_data, task_info)
else:
# --- UNKNOWN: Unrecognized status ---
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_task_status(task_id, stored_data)
store_task_status(task_id, progress_data)
def _handle_initializing(self, task_id, data, task_info):
"""Handle initializing status from deezspot"""
@@ -663,7 +688,7 @@ class ProgressTrackingTask(Task):
store_task_info(task_id, task_info)
# Update status in data
data["status"] = ProgressState.INITIALIZING
# data["status"] = ProgressState.INITIALIZING
def _handle_downloading(self, task_id, data, task_info):
"""Handle downloading status from deezspot"""
@@ -720,7 +745,7 @@ class ProgressTrackingTask(Task):
logger.info(f"Task {task_id} downloading: '{track_name}'")
# Update status
data["status"] = ProgressState.DOWNLOADING
# data["status"] = ProgressState.DOWNLOADING
def _handle_progress(self, task_id, data, task_info):
"""Handle progress status from deezspot"""
@@ -776,7 +801,7 @@ class ProgressTrackingTask(Task):
logger.error(f"Error parsing track numbers '{current_track_raw}': {e}")
# Ensure correct status
data["status"] = ProgressState.PROGRESS
# data["status"] = ProgressState.PROGRESS
def _handle_real_time(self, task_id, data):
"""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}%")
# Set appropriate status
data["status"] = (
ProgressState.REAL_TIME
if data.get("status") == "real_time"
else ProgressState.TRACK_PROGRESS
)
# data["status"] = (
# ProgressState.REAL_TIME
# if data.get("status") == "real_time"
# else ProgressState.TRACK_PROGRESS
# )
def _handle_skipped(self, task_id, data, task_info):
"""Handle skipped status from deezspot"""
@@ -872,7 +897,7 @@ class ProgressTrackingTask(Task):
store_task_status(task_id, progress_update)
# Set status
data["status"] = ProgressState.SKIPPED
# data["status"] = ProgressState.SKIPPED
def _handle_retrying(self, task_id, data, task_info):
"""Handle retrying status from deezspot"""
@@ -895,7 +920,7 @@ class ProgressTrackingTask(Task):
store_task_info(task_id, task_info)
# Set status
data["status"] = ProgressState.RETRYING
# data["status"] = ProgressState.RETRYING
def _handle_error(self, task_id, data, task_info):
"""Handle error status from deezspot"""
@@ -911,7 +936,7 @@ class ProgressTrackingTask(Task):
store_task_info(task_id, task_info)
# Set status and error message
data["status"] = ProgressState.ERROR
# data["status"] = ProgressState.ERROR
data["error"] = message
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}'")
# Update status to track_complete
data["status"] = ProgressState.TRACK_COMPLETE
# data["status"] = ProgressState.TRACK_COMPLETE
# Update task info
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()}")
# Add summary
data["status"] = ProgressState.COMPLETE
data["message"] = (
f"Download complete: {completed_tracks} tracks downloaded, {skipped_tracks} skipped"
)
# data["status"] = ProgressState.COMPLETE
summary_obj = data.get("summary")
# Log summary
logger.info(
f"Task {task_id} summary: {completed_tracks} completed, {skipped_tracks} skipped, {error_count} errors"
)
if summary_obj:
total_successful = summary_obj.get("total_successful", 0)
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
delayed_delete_task_data.apply_async(
args=[task_id, "Task completed successfully and auto-cleaned."],
@@ -1066,8 +1104,8 @@ class ProgressTrackingTask(Task):
else:
# Generic done for other types
logger.info(f"Task {task_id} completed: {content_type.upper()}")
data["status"] = ProgressState.COMPLETE
data["message"] = "Download complete"
# data["status"] = ProgressState.COMPLETE
# data["message"] = "Download complete"
# Celery signal handlers
@@ -1134,18 +1172,11 @@ def task_postrun_handler(
)
if state == states.SUCCESS:
if current_redis_status != ProgressState.COMPLETE:
store_task_status(
task_id,
{
"status": ProgressState.COMPLETE,
"timestamp": time.time(),
"type": task_info.get("type", "unknown"),
"name": task_info.get("name", "Unknown"),
"artist": task_info.get("artist", ""),
"message": "Download completed successfully.",
},
)
if current_redis_status not in [ProgressState.COMPLETE, "done"]:
# The final status is now set by the 'done' callback from deezspot.
# We no longer need to store a generic 'COMPLETE' status here.
# This ensures the raw callback data is the last thing in the log.
pass
logger.info(
f"Task {task_id} completed successfully: {task_info.get('name', 'Unknown')}"
)
@@ -1335,8 +1366,8 @@ def download_track(self, **task_data):
# Determine service parameters
if service == "spotify":
if fallback_enabled:
main = config_params.get("deezer", "")
fallback = config_params.get("spotify", "")
main = config_params.get("spotify", "")
fallback = config_params.get("deezer", "")
quality = config_params.get("deezerQuality", "MP3_128")
fall_quality = config_params.get("spotifyQuality", "NORMAL")
else:
@@ -1421,8 +1452,8 @@ def download_album(self, **task_data):
# Determine service parameters
if service == "spotify":
if fallback_enabled:
main = config_params.get("deezer", "")
fallback = config_params.get("spotify", "")
main = config_params.get("spotify", "")
fallback = config_params.get("deezer", "")
quality = config_params.get("deezerQuality", "MP3_128")
fall_quality = config_params.get("spotifyQuality", "NORMAL")
else:
@@ -1507,8 +1538,8 @@ def download_playlist(self, **task_data):
# Determine service parameters
if service == "spotify":
if fallback_enabled:
main = config_params.get("deezer", "")
fallback = config_params.get("spotify", "")
main = config_params.get("spotify", "")
fallback = config_params.get("deezer", "")
quality = config_params.get("deezerQuality", "MP3_128")
fall_quality = config_params.get("spotifyQuality", "NORMAL")
else:

View File

@@ -403,6 +403,9 @@ def get_credential(service, name):
"name": data.get("name"),
"region": data.get("region"),
"blob_content": data.get("blob_content"),
"blob_file_path": data.get(
"blob_file_path"
), # Ensure blob_file_path is returned
}
return cleaned_data

View File

@@ -2,6 +2,7 @@ import sqlite3
import json
import time
import logging
import uuid
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -27,6 +28,12 @@ EXPECTED_COLUMNS = {
"quality_profile": "TEXT",
"convert_to": "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,
quality_profile 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)
@@ -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."
)
# 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:
conn.commit()
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",
"convert_to",
"bitrate",
"parent_task_id",
"track_status",
"summary_json",
"total_successful",
"total_skipped",
"total_failed",
]
# Ensure all keys are present, filling with None if not
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,
timestamp_added, timestamp_completed, original_request_json,
last_status_obj_json, service_used, quality_profile,
convert_to, bitrate
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
convert_to, bitrate, parent_task_id, track_status,
summary_json, total_successful, total_skipped, total_failed
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
history_data["task_id"],
@@ -185,6 +226,12 @@ def add_entry_to_history(history_data: dict):
history_data["quality_profile"],
history_data["convert_to"],
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()
@@ -239,8 +286,16 @@ def get_history_entries(
for column, value in filters.items():
# Basic security: ensure column is a valid one (alphanumeric + underscore)
if column.replace("_", "").isalnum():
where_clauses.append(f"{column} = ?")
params.append(value)
# Special case for 'NOT_NULL' value for parent_task_id
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:
where_sql = " WHERE " + " AND ".join(where_clauses)
@@ -266,6 +321,11 @@ def get_history_entries(
"quality_profile",
"convert_to",
"bitrate",
"parent_task_id",
"track_status",
"total_successful",
"total_skipped",
"total_failed",
]
if sort_by not in valid_sort_columns:
sort_by = "timestamp_completed" # Default sort
@@ -292,6 +352,157 @@ def get_history_entries(
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__":
# For testing purposes
logging.basicConfig(level=logging.INFO)

View File

@@ -124,6 +124,10 @@ def download_playlist(
"spotify", main
) # For blob 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():
raise FileNotFoundError(
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
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():
raise FileNotFoundError(
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 statusFilter = document.getElementById('status-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 limit = 25;
let totalEntries = 0;
let currentSortBy = 'timestamp_completed';
let currentSortOrder = 'DESC';
let currentParentTaskId: string | null = null;
async function fetchHistory(page = 1) {
if (!historyTableBody || !prevButton || !nextButton || !pageInfo || !limitSelect || !statusFilter || !typeFilter) {
@@ -30,6 +33,21 @@ document.addEventListener('DOMContentLoaded', () => {
if (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 {
const response = await fetch(apiUrl);
@@ -42,10 +60,13 @@ document.addEventListener('DOMContentLoaded', () => {
currentPage = Math.floor(offset / limit) + 1;
updatePagination();
updateSortIndicators();
// Update page title if viewing tracks for a parent
updatePageTitle();
} catch (error) {
console.error('Error fetching history:', error);
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
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;
}
entries.forEach(entry => {
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.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';
// Construct Quality display string
const qualityCell = row.insertCell();
let qualityDisplay = entry.quality_profile || 'N/A';
if (entry.convert_to) {
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)
qualityDisplay = `${entry.bitrate}k (${entry.quality_profile || 'Profile'})`;
}
row.insertCell().textContent = qualityDisplay;
qualityCell.textContent = qualityDisplay;
const statusCell = row.insertCell();
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_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');
detailsButton.innerHTML = `<img src="/static/images/info.svg" alt="Details">`;
detailsButton.className = 'details-btn btn-icon';
detailsButton.title = 'Show Details';
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) {
const errorSpan = document.createElement('span');
@@ -105,10 +177,8 @@ document.addEventListener('DOMContentLoaded', () => {
errorDetailsDiv = document.createElement('div');
errorDetailsDiv.className = 'error-details';
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);
// 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;
// Toggle display by directly manipulating the style of the details div
@@ -127,27 +197,92 @@ document.addEventListener('DOMContentLoaded', () => {
prevButton.disabled = currentPage === 1;
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) {
const details = `Task ID: ${entry.task_id}\n` +
`Type: ${entry.download_type}\n` +
`Name: ${entry.item_name}\n` +
`Artist: ${entry.item_artist}\n` +
`Album: ${entry.item_album || 'N/A'}\n` +
`URL: ${entry.item_url}\n` +
`Spotify ID: ${entry.spotify_id || 'N/A'}\n` +
`Service Used: ${entry.service_used || 'N/A'}\n` +
`Quality Profile (Original): ${entry.quality_profile || 'N/A'}\n` +
`ConvertTo: ${entry.convert_to || 'N/A'}\n` +
`Bitrate: ${entry.bitrate ? entry.bitrate + 'k' : 'N/A'}\n` +
`Status: ${entry.status_final}\n` +
`Error: ${entry.error_message || 'None'}\n` +
`Added: ${new Date(entry.timestamp_added * 1000).toLocaleString()}\n` +
`Completed/Ended: ${new Date(entry.timestamp_completed * 1000).toLocaleString()}\n\n` +
`Original 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)}`;
// Create more detailed modal content with new fields
let details = `Task ID: ${entry.task_id}\n` +
`Type: ${entry.download_type}\n` +
`Name: ${entry.item_name}\n` +
`Artist: ${entry.item_artist}\n` +
`Album: ${entry.item_album || 'N/A'}\n` +
`URL: ${entry.item_url || 'N/A'}\n` +
`Spotify ID: ${entry.spotify_id || 'N/A'}\n` +
`Service Used: ${entry.service_used || 'N/A'}\n` +
`Quality Profile (Original): ${entry.quality_profile || 'N/A'}\n` +
`ConvertTo: ${entry.convert_to || 'N/A'}\n` +
`Bitrate: ${entry.bitrate ? entry.bitrate + 'k' : 'N/A'}\n` +
`Status: ${entry.status_final}\n` +
`Error: ${entry.error_message || 'None'}\n` +
`Added: ${new Date(entry.timestamp_added * 1000).toLocaleString()}\n` +
`Completed/Ended: ${new Date(entry.timestamp_completed * 1000).toLocaleString()}\n`;
// 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);
}
// 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 => {
headerCell.addEventListener('click', () => {
@@ -174,6 +309,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// Event listeners for pagination and filters
prevButton?.addEventListener('click', () => fetchHistory(currentPage - 1));
nextButton?.addEventListener('click', () => fetchHistory(currentPage + 1));
limitSelect?.addEventListener('change', (e) => {
@@ -182,6 +318,8 @@ document.addEventListener('DOMContentLoaded', () => {
});
statusFilter?.addEventListener('change', () => fetchHistory(1));
typeFilter?.addEventListener('change', () => fetchHistory(1));
trackFilter?.addEventListener('change', () => fetchHistory(1));
hideChildTracksCheckbox?.addEventListener('change', () => fetchHistory(1));
// Initial fetch
fetchHistory();

View File

@@ -1,4 +1,3 @@
// --- MODIFIED: Custom URLSearchParams class that does not encode anything ---
class CustomURLSearchParams {
params: Record<string, string>;
constructor() {
@@ -13,7 +12,6 @@ class CustomURLSearchParams {
.join('&');
}
}
// --- END MODIFIED ---
// Interfaces for complex objects
interface QueueItem {
@@ -48,35 +46,49 @@ interface ParentInfo {
}
interface StatusData {
type?: string;
status?: string;
name?: string;
song?: string;
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;
type?: 'track' | 'album' | 'playlist' | 'episode' | string;
status?: 'initializing' | 'skipped' | 'retrying' | 'real-time' | 'error' | 'done' | 'processing' | 'queued' | 'progress' | 'track_progress' | 'complete' | 'cancelled' | 'cancel' | 'interrupted' | string;
// --- Standardized Fields ---
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;
// 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;
position?: number; // For queued items
position?: number;
original_request?: {
url?: string;
retry_url?: string;
@@ -89,11 +101,12 @@ interface StatusData {
display_type?: string;
display_artist?: string;
service?: string;
[key: string]: any; // For other potential original_request params
[key: string]: any;
};
event?: string; // from SSE
event?: string;
overall_progress?: number;
display_type?: string; // from PRG data
display_type?: string;
[key: string]: any; // Allow other properties
}
@@ -231,26 +244,20 @@ export class DownloadQueue {
// Load initial config from the server.
await this.loadConfig();
// Override the server value with locally persisted queue visibility (if present).
// Use localStorage for queue visibility
const storedVisible = localStorage.getItem("downloadQueueVisible");
if (storedVisible !== null) {
// Ensure config is not null before assigning
if (this.config) {
this.config.downloadQueueVisible = storedVisible === "true";
}
}
const isVisible = storedVisible === "true";
const queueSidebar = document.getElementById('downloadQueue');
// Ensure config is not null and queueSidebar exists
if (this.config && queueSidebar) {
queueSidebar.hidden = !this.config.downloadQueueVisible;
queueSidebar.classList.toggle('active', !!this.config.downloadQueueVisible);
if (queueSidebar) {
queueSidebar.hidden = !isVisible;
queueSidebar.classList.toggle('active', isVisible);
}
// Initialize the queue icon based on sidebar visibility
const queueIcon = document.getElementById('queueIcon');
if (queueIcon && this.config) {
if (this.config.downloadQueueVisible) {
if (queueIcon) {
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
@@ -328,7 +335,7 @@ export class DownloadQueue {
// Update the queue icon to show X when visible or queue icon when hidden
const queueIcon = document.getElementById('queueIcon');
if (queueIcon && this.config) {
if (queueIcon) {
if (isVisible) {
// Replace the image with an X and add red tint
queueIcon.innerHTML = '<img src="/static/images/cross.svg" alt="Close queue" class="queue-x">';
@@ -342,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));
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');
}
this.dispatchEvent('queueVisibilityChanged', { visible: isVisible });
if (isVisible) {
// If the queue is now visible, ensure all visible items are being polled.
@@ -646,7 +628,7 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...';
// 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 displayType = type.charAt(0).toUpperCase() + type.slice(1);
@@ -1039,9 +1021,9 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
}
// 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';
const artist = data.artist || data.artist_name ||
const artist = data.artist ||
(queueItem?.item?.artist) || '';
const albumTitle = data.title || data.album || data.parent?.title || data.name ||
(queueItem?.item?.name) || '';
@@ -1049,18 +1031,14 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
(queueItem?.item?.name) || '';
const playlistOwner = data.owner || data.parent?.owner ||
(queueItem?.item?.owner) || ''; // Add type check if item.owner is object
const currentTrack = data.current_track || data.parsed_current_track || '';
const totalTracks = data.total_tracks || data.parsed_total_tracks || data.parent?.total_tracks ||
const currentTrack = data.current_track || '';
const totalTracks = data.total_tracks || data.parent?.total_tracks ||
(queueItem?.item?.total_tracks) || '';
// Format percentage for display when available
let formattedPercentage = '0';
if (data.progress !== undefined) {
formattedPercentage = parseFloat(data.progress as string).toFixed(1); // Cast to string
} 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
formattedPercentage = Number(data.progress).toFixed(1);
}
// Helper for constructing info about the parent item
@@ -1206,11 +1184,37 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
case 'done':
case 'complete':
if (data.type === 'track') {
return `Downloaded "${trackName}"${artist ? ` by ${artist}` : ''} successfully${getParentInfo()}`;
} else if (data.type === 'album') {
// Final summary for album/playlist
if (data.summary && (data.type === 'album' || data.type === 'playlist')) {
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)`;
} else if (data.type === 'playlist') {
}
if (data.type === 'playlist') {
return `Downloaded playlist "${playlistName}"${playlistOwner ? ` by ${playlistOwner}` : ''} successfully (${totalTracks} tracks)`;
}
return `Downloaded ${data.type} successfully`;
@@ -1278,6 +1282,12 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
/* New Methods to Handle Terminal State, Inactivity and Auto-Retry */
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
entry.hasEnded = true;
@@ -1294,10 +1304,11 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Stop polling
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 :
(progress && typeof progress !== 'number' && progress.status === 'error') ? 10000 :
(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
setTimeout(() => {
@@ -1657,7 +1668,7 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
if (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';
@@ -1755,7 +1766,6 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
} catch (error) {
console.error('Error loading config:', error);
this.config = { // Initialize with a default structure on error
downloadQueueVisible: false,
maxRetries: 3,
retryDelaySeconds: 5,
retry_delay_increase: 5,
@@ -1764,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
isExplicitFilterEnabled(): boolean { // Add return type
return !!this.config.explicitFilter;
@@ -1891,6 +1886,15 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Handle terminal states
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}`);
// 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;
// For cancelled downloads, clean up immediately
@@ -1955,22 +1959,42 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Extract the actual status data from the API response
const statusData: StatusData = data.last_line || {}; // Add type
// Special handling for track status updates that are part of an album/playlist
// We want to keep these for showing the track-by-track progress
if (statusData.type === 'track' && statusData.parent) {
// If this is a track that's part of our album/playlist, keep it
if ((entry.type === 'album' && statusData.parent.type === 'album') ||
(entry.type === 'playlist' && statusData.parent.type === 'playlist')) {
console.log(`Processing track status update for ${entry.type}: ${statusData.song}`);
}
// --- Normalize statusData to conform to expected types ---
const numericFields = ['current_track', 'total_tracks', 'progress', 'retry_count', 'seconds_left', 'time_elapsed'];
for (const field of numericFields) {
if (statusData[field] !== undefined && typeof statusData[field] === 'string') {
statusData[field] = parseFloat(statusData[field] as string);
}
}
// 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 &&
(!statusData.parent || statusData.parent.type !== entry.type)) {
console.log(`Skipping mismatched type: update=${statusData.type}, entry=${entry.type}`);
return;
const entryType = entry.type;
const updateType = statusData.type;
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
let status = statusData.status || data.event || 'unknown'; // Define status *before* potential modification
@@ -2064,6 +2088,32 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Apply appropriate status classes
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
if (status === 'error') {
entry.hasEnded = true;
@@ -2148,9 +2198,23 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
}
// Handle terminal states for non-error cases
if (['complete', 'cancel', 'cancelled', 'done', 'skipped'].includes(status)) {
entry.hasEnded = true;
this.handleDownloadCompletion(entry, queueId, statusData);
if (['complete', 'done', 'skipped', 'cancelled', 'cancel'].includes(status)) {
// Only mark as ended if the update type matches the entry type.
// 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
@@ -2213,7 +2277,7 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
if (trackProgressBar && statusData.progress !== undefined) {
// Update track progress bar
const progress = parseFloat(statusData.progress as string); // Cast to string
const progress = Number(statusData.progress);
trackProgressBar.style.width = `${progress}%`;
trackProgressBar.setAttribute('aria-valuenow', progress.toString()); // Use string
@@ -2323,11 +2387,7 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Real-time progress for direct track download
if (statusData.status === 'real-time' && statusData.progress !== undefined) {
progress = parseFloat(statusData.progress as string); // Cast to string
} 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
progress = Number(statusData.progress);
} else if (statusData.status === 'done' || statusData.status === 'complete') {
progress = 100;
} else if (statusData.current_track && statusData.total_tracks) {
@@ -2374,6 +2434,44 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
let totalTracks = 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
if (statusData.type === 'track' && statusData.parent &&
(entry.type === 'album' || entry.type === 'playlist')) {
@@ -2401,6 +2499,12 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Get current track and total tracks from the status data
if (statusData.current_track !== undefined) {
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
if (statusData.total_tracks !== undefined) {
@@ -2414,7 +2518,10 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Get track progress for real-time updates
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
@@ -2426,7 +2533,9 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
if (logElement && statusData.song && statusData.artist) {
let progressInfo = '';
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})`;
}
@@ -2434,16 +2543,23 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
// Calculate and update the overall progress bar
if (totalTracks > 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 currentTrackContribution = (1 / totalTracks) * (trackProgress / 100);
overallProgress = (completedTracksProgress + currentTrackContribution) * 100;
console.log(`Overall progress: ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks}, Progress: ${trackProgress}%)`);
} else {
// Fallback to track count method
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
if (overallProgressBar) {
@@ -2466,23 +2582,36 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
trackProgressContainer.style.display = 'block';
}
if (statusData.status === 'real-time') {
// Real-time progress for the current track
const safeTrackProgress = Math.max(0, Math.min(100, trackProgress));
trackProgressBar.style.width = `${safeTrackProgress}%`;
trackProgressBar.setAttribute('aria-valuenow', safeTrackProgress.toString()); // Use string
if (statusData.status === 'real-time' || statusData.status === 'real_time') {
// For real-time updates, use the track progress for the small green progress bar
// This shows download progress for the current track only
const safeProgress = isNaN(trackProgress) ? 0 : Math.max(0, Math.min(100, trackProgress));
trackProgressBar.style.width = `${safeProgress}%`;
trackProgressBar.setAttribute('aria-valuenow', String(safeProgress));
trackProgressBar.classList.add('real-time');
if (safeTrackProgress >= 100) {
if (safeProgress >= 100) {
trackProgressBar.classList.add('complete');
} else {
trackProgressBar.classList.remove('complete');
}
} else {
// Indeterminate progress animation for non-real-time updates
} else if (statusData.status === 'done' || statusData.status === 'complete') {
// 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.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));
}
}
@@ -2525,18 +2654,21 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
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
if (statusData.status === 'real-time' && statusData.progress !== undefined) {
// For real-time downloads, progress comes as a percentage value (0-100)
trackProgress = parseFloat(statusData.progress as string); // Cast to string
} 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
trackProgress = Number(statusData.progress); // Cast to number
} else if (statusData.status === 'done' || statusData.status === 'complete') {
progress = 100;
trackProgress = 100; // Also set trackProgress to 100% for completed status
} else if (statusData.current_track && statusData.total_tracks) {
// 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
@@ -2732,6 +2864,18 @@ createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string):
const serverEquivalent = serverTasks.find(st => st.task_id === localEntry.taskId);
if (serverEquivalent && serverEquivalent.last_status_obj && terminalStates.includes(serverEquivalent.last_status_obj.status)) {
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.`);
this.handleDownloadCompletion(localEntry, localEntry.uniqueId, serverEquivalent.last_status_obj);
}

View File

@@ -38,6 +38,71 @@ tr:nth-child(even) {
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 {
margin-top: 20px;
text-align: center;
@@ -63,6 +128,7 @@ tr:nth-child(even) {
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.filters label, .filters select, .filters input {
@@ -77,9 +143,16 @@ tr:nth-child(even) {
border-radius: 4px;
}
.checkbox-filter {
display: flex;
align-items: center;
gap: 5px;
}
.status-COMPLETED { color: #1DB954; font-weight: bold; }
.status-ERROR { color: #FF4136; font-weight: bold; }
.status-CANCELLED { color: #AAAAAA; }
.status-skipped { color: #FFD700; font-weight: bold; }
.error-message-toggle {
cursor: pointer;
@@ -97,8 +170,8 @@ tr:nth-child(even) {
font-size: 0.9em;
}
/* Styling for the Details icon button in the table */
.details-btn {
/* Styling for the buttons in the table */
.btn-icon {
background-color: transparent; /* Or a subtle color like #282828 */
border: none;
border-radius: 50%; /* Make it circular */
@@ -108,14 +181,23 @@ tr:nth-child(even) {
align-items: center;
justify-content: center;
transition: background-color 0.2s ease;
margin-right: 5px;
}
.details-btn img {
.btn-icon img {
width: 16px; /* Icon size */
height: 16px;
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 */
}
.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;
}
/* ----------------------------- */
/* 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 */
.error-buttons button {
border: none;

View File

@@ -19,7 +19,7 @@
</head>
<body>
<div class="container">
<h1>Download History</h1>
<h1 id="history-title">Download History</h1>
<div class="filters">
<label for="status-filter">Status:</label>
@@ -38,6 +38,19 @@
<option value="playlist">Playlist</option>
<option value="artist">Artist</option>
</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>
<table>
@@ -45,13 +58,13 @@
<tr>
<th data-sort="item_name">Name</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="quality_profile">Quality</th>
<th data-sort="status_final">Status</th>
<th data-sort="timestamp_added">Date Added</th>
<th data-sort="timestamp_completed">Date Completed/Ended</th>
<th>Details</th>
<th>Actions</th>
</tr>
</thead>
<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

View File

@@ -11,7 +11,7 @@ load_dotenv()
# --- Environment-based secrets for testing ---
SPOTIFY_API_CLIENT_ID = os.environ.get("SPOTIFY_API_CLIENT_ID", "your_spotify_client_id")
SPOTIFY_API_CLIENT_SECRET = os.environ.get("SPOTIFY_API_CLIENT_SECRET", "your_spotify_client_secret")
SPOTIFY_BLOB_CONTENT_STR = os.environ.get("SPOTIFY_BLOB_CONTENT_STR", '{}')
SPOTIFY_BLOB_CONTENT_STR = os.environ.get("SPOTIFY_BLOB_CONTENT", '{}')
try:
SPOTIFY_BLOB_CONTENT = json.loads(SPOTIFY_BLOB_CONTENT_STR)
except json.JSONDecodeError:
@@ -46,12 +46,12 @@ def wait_for_task(base_url, task_id, timeout=600):
response.raise_for_status() # Raise an exception for bad status codes
statuses = response.json()
if not statuses:
data = response.json()
if not data or not data.get("last_line"):
time.sleep(1)
continue
last_status = statuses[-1]
last_status = data["last_line"]
status = last_status.get("status")
# More verbose logging for debugging during tests

View File

@@ -20,17 +20,25 @@ def test_get_main_config(base_url):
assert "maxConcurrentDownloads" in config
assert "spotify" in config # Should be set by conftest
assert "deezer" in config # Should be set by conftest
assert "fallback" in config
assert "realTime" in config
assert "maxRetries" in config
def test_update_main_config(base_url, reset_config):
"""Tests updating various fields in the main configuration."""
"""Tests updating various fields in the main configuration based on frontend capabilities."""
new_settings = {
"maxConcurrentDownloads": 5,
"spotifyQuality": "HIGH",
"deezerQuality": "MP3_128",
"deezerQuality": "FLAC",
"customDirFormat": "%artist%/%album%",
"customTrackFormat": "%tracknum% %title%",
"save_cover": False,
"fallback": True,
"realTime": False,
"maxRetries": 5,
"retryDelaySeconds": 10,
"retry_delay_increase": 10,
"tracknum_padding": False,
}
response = requests.post(f"{base_url}/config", json=new_settings)
@@ -45,8 +53,9 @@ def test_get_watch_config(base_url):
response = requests.get(f"{base_url}/config/watch")
assert response.status_code == 200
config = response.json()
assert "delay_between_playlists_seconds" in config
assert "delay_between_artists_seconds" in config
assert "enabled" in config
assert "watchPollIntervalSeconds" in config
assert "watchedArtistAlbumGroup" in config
def test_update_watch_config(base_url):
"""Tests updating the watch-specific configuration."""
@@ -54,14 +63,19 @@ def test_update_watch_config(base_url):
original_config = response.json()
new_settings = {
"delay_between_playlists_seconds": 120,
"delay_between_artists_seconds": 240,
"auto_add_new_releases_to_queue": False,
"enabled": False,
"watchPollIntervalSeconds": 7200,
"watchedArtistAlbumGroup": ["album", "single"],
}
response = requests.post(f"{base_url}/config/watch", json=new_settings)
assert response.status_code == 200
updated_config = response.json()
# The response for updating watch config is just a success message,
# so we need to GET the config again to verify.
verify_response = requests.get(f"{base_url}/config/watch")
assert verify_response.status_code == 200
updated_config = verify_response.json()
for key, value in new_settings.items():
assert updated_config[key] == value
@@ -71,24 +85,29 @@ def test_update_watch_config(base_url):
def test_update_conversion_config(base_url, reset_config):
"""
Iterates through all supported conversion formats and bitrates,
updating the config and verifying the changes for each combination.
Iterates through supported conversion formats and bitrates from the frontend,
updating the config and verifying the changes.
"""
conversion_formats = ["mp3", "flac", "ogg", "opus", "m4a"]
# Formats and bitrates aligned with src/js/config.ts
conversion_formats = ["MP3", "AAC", "OGG", "OPUS", "FLAC", "WAV", "ALAC"]
bitrates = {
"mp3": ["320", "256", "192", "128"],
"ogg": ["500", "320", "192", "160"],
"opus": ["256", "192", "128", "96"],
"m4a": ["320k", "256k", "192k", "128k"],
"flac": [None] # Bitrate is not applicable for FLAC
"MP3": ["128k", "320k"],
"AAC": ["128k", "256k"],
"OGG": ["128k", "320k"],
"OPUS": ["96k", "256k"],
"FLAC": [None],
"WAV": [None],
"ALAC": [None],
}
for format in conversion_formats:
for br in bitrates.get(format, [None]):
print(f"Testing conversion config: format={format}, bitrate={br}")
new_settings = {"convertTo": format, "bitrate": br}
for format_val in conversion_formats:
for br in bitrates.get(format_val, [None]):
print(f"Testing conversion config: format={format_val}, bitrate={br}")
new_settings = {"convertTo": format_val, "bitrate": br}
response = requests.post(f"{base_url}/config", json=new_settings)
assert response.status_code == 200
updated_config = response.json()
assert updated_config["convertTo"] == format
assert updated_config["convertTo"] == format_val
# The backend might return null for empty bitrate, which is fine
assert updated_config["bitrate"] == br

View File

@@ -1,7 +1,9 @@
import requests
import pytest
import os
import shutil
# URLs provided by the user for testing
# URLs for testing
SPOTIFY_TRACK_URL = "https://open.spotify.com/track/1Cts4YV9aOXVAP3bm3Ro6r"
SPOTIFY_ALBUM_URL = "https://open.spotify.com/album/4K0JVP5veNYTVI6IMamlla"
SPOTIFY_PLAYLIST_URL = "https://open.spotify.com/playlist/26CiMxIxdn5WhXyccMCPOB"
@@ -13,68 +15,101 @@ ALBUM_ID = SPOTIFY_ALBUM_URL.split('/')[-1].split('?')[0]
PLAYLIST_ID = SPOTIFY_PLAYLIST_URL.split('/')[-1].split('?')[0]
ARTIST_ID = SPOTIFY_ARTIST_URL.split('/')[-1].split('?')[0]
DOWNLOAD_DIR = "downloads/"
def get_downloaded_files(directory=DOWNLOAD_DIR):
"""Walks a directory and returns a list of all file paths."""
file_paths = []
if not os.path.isdir(directory):
return file_paths
for root, _, files in os.walk(directory):
for file in files:
# Ignore hidden files like .DS_Store
if not file.startswith('.'):
file_paths.append(os.path.join(root, file))
return file_paths
@pytest.fixture(autouse=True)
def cleanup_downloads_dir():
"""
Ensures the download directory is removed and recreated, providing a clean
slate before and after each test.
"""
if os.path.exists(DOWNLOAD_DIR):
shutil.rmtree(DOWNLOAD_DIR)
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
yield
if os.path.exists(DOWNLOAD_DIR):
shutil.rmtree(DOWNLOAD_DIR)
@pytest.fixture
def reset_config(base_url):
"""Fixture to reset the main config after a test to avoid side effects."""
"""
Fixture to get original config, set single concurrent download for test
isolation, and restore the original config after the test.
"""
response = requests.get(f"{base_url}/config")
original_config = response.json()
# Set max concurrent downloads to 1 for all tests using this fixture.
requests.post(f"{base_url}/config", json={"maxConcurrentDownloads": 1})
yield
# Restore original config
requests.post(f"{base_url}/config", json=original_config)
def test_download_track_spotify_only(base_url, task_waiter, reset_config):
"""Tests downloading a single track from Spotify with real-time download enabled."""
print("\n--- Testing Spotify-only track download ---")
@pytest.mark.parametrize("download_type, item_id, timeout, expected_files_min", [
("track", TRACK_ID, 600, 1),
("album", ALBUM_ID, 900, 14), # "After Hours" has 14 tracks
("playlist", PLAYLIST_ID, 1200, 4), # Test playlist has 4 tracks
])
def test_spotify_download_and_verify_files(base_url, task_waiter, reset_config, download_type, item_id, timeout, expected_files_min):
"""
Tests downloading a track, album, or playlist and verifies that the
expected number of files are created on disk.
"""
print(f"\n--- Testing Spotify-only '{download_type}' download and verifying files ---")
config_payload = {
"service": "spotify",
"fallback": False,
"realTime": True,
"spotifyQuality": "NORMAL" # Simulating free account quality
"spotifyQuality": "NORMAL"
}
requests.post(f"{base_url}/config", json=config_payload)
response = requests.get(f"{base_url}/track/download/{TRACK_ID}")
response = requests.get(f"{base_url}/{download_type}/download/{item_id}")
assert response.status_code == 202
task_id = response.json()["task_id"]
final_status = task_waiter(task_id)
assert final_status["status"] == "complete", f"Task failed: {final_status.get('error')}"
final_status = task_waiter(task_id, timeout=timeout)
assert final_status["status"] == "complete", f"Task failed for {download_type} {item_id}: {final_status.get('error')}"
def test_download_album_spotify_only(base_url, task_waiter, reset_config):
"""Tests downloading a full album from Spotify with real-time download enabled."""
print("\n--- Testing Spotify-only album download ---")
config_payload = {"service": "spotify", "fallback": False, "realTime": True, "spotifyQuality": "NORMAL"}
requests.post(f"{base_url}/config", json=config_payload)
# Verify that the correct number of files were downloaded
downloaded_files = get_downloaded_files()
assert len(downloaded_files) >= expected_files_min, (
f"Expected at least {expected_files_min} file(s) for {download_type} {item_id}, "
f"but found {len(downloaded_files)}."
)
response = requests.get(f"{base_url}/album/download/{ALBUM_ID}")
assert response.status_code == 202
task_id = response.json()["task_id"]
final_status = task_waiter(task_id, timeout=900)
assert final_status["status"] == "complete", f"Task failed: {final_status.get('error')}"
def test_download_playlist_spotify_only(base_url, task_waiter, reset_config):
"""Tests downloading a full playlist from Spotify with real-time download enabled."""
print("\n--- Testing Spotify-only playlist download ---")
config_payload = {"service": "spotify", "fallback": False, "realTime": True, "spotifyQuality": "NORMAL"}
requests.post(f"{base_url}/config", json=config_payload)
response = requests.get(f"{base_url}/playlist/download/{PLAYLIST_ID}")
assert response.status_code == 202
task_id = response.json()["task_id"]
final_status = task_waiter(task_id, timeout=1200)
assert final_status["status"] == "complete", f"Task failed: {final_status.get('error')}"
def test_download_artist_spotify_only(base_url, task_waiter, reset_config):
"""Tests queuing downloads for an artist's entire discography from Spotify."""
print("\n--- Testing Spotify-only artist download ---")
def test_artist_download_and_verify_files(base_url, task_waiter, reset_config):
"""
Tests queuing an artist download and verifies that files are created.
Does not check for exact file count due to the variability of artist discographies.
"""
print("\n--- Testing Spotify-only artist download and verifying files ---")
config_payload = {"service": "spotify", "fallback": False, "realTime": True, "spotifyQuality": "NORMAL"}
requests.post(f"{base_url}/config", json=config_payload)
response = requests.get(f"{base_url}/artist/download/{ARTIST_ID}?album_type=album,single")
assert response.status_code == 202
response_data = response.json()
queued_albums = response_data.get("successfully_queued_albums", [])
queued_albums = response_data.get("queued_albums", [])
assert len(queued_albums) > 0, "No albums were queued for the artist."
for album in queued_albums:
@@ -83,13 +118,18 @@ def test_download_artist_spotify_only(base_url, task_waiter, reset_config):
final_status = task_waiter(task_id, timeout=900)
assert final_status["status"] == "complete", f"Artist album task {album['name']} failed: {final_status.get('error')}"
def test_download_track_with_fallback(base_url, task_waiter, reset_config):
"""Tests downloading a Spotify track with Deezer fallback enabled."""
print("\n--- Testing track download with Deezer fallback ---")
# After all tasks complete, verify that at least some files were downloaded.
downloaded_files = get_downloaded_files()
assert len(downloaded_files) > 0, "Artist download ran but no files were found in the download directory."
def test_download_with_deezer_fallback_and_verify_files(base_url, task_waiter, reset_config):
"""Tests downloading with Deezer fallback and verifies the file exists."""
print("\n--- Testing track download with Deezer fallback and verifying files ---")
config_payload = {
"service": "spotify",
"fallback": True,
"deezerQuality": "MP3_320" # Simulating higher quality from Deezer free
"deezerQuality": "FLAC" # Test with high quality fallback
}
requests.post(f"{base_url}/config", json=config_payload)
@@ -98,24 +138,58 @@ def test_download_track_with_fallback(base_url, task_waiter, reset_config):
task_id = response.json()["task_id"]
final_status = task_waiter(task_id)
assert final_status["status"] == "complete", f"Task failed: {final_status.get('error')}"
assert final_status["status"] == "complete", f"Task failed with fallback: {final_status.get('error')}"
@pytest.mark.parametrize("format,bitrate", [
("mp3", "320"), ("mp3", "128"),
("flac", None),
("ogg", "160"),
("opus", "128"),
("m4a", "128k")
# Verify that at least one file was downloaded.
downloaded_files = get_downloaded_files()
assert len(downloaded_files) >= 1, "Fallback download completed but no file was found."
def test_download_without_realtime_and_verify_files(base_url, task_waiter, reset_config):
"""Tests a non-realtime download and verifies the file exists."""
print("\n--- Testing download with realTime: False and verifying files ---")
config_payload = {
"service": "spotify",
"fallback": False,
"realTime": False,
"spotifyQuality": "NORMAL"
}
requests.post(f"{base_url}/config", json=config_payload)
response = requests.get(f"{base_url}/track/download/{TRACK_ID}")
assert response.status_code == 202
task_id = response.json()["task_id"]
final_status = task_waiter(task_id)
assert final_status["status"] == "complete", f"Task failed with realTime=False: {final_status.get('error')}"
# Verify that at least one file was downloaded.
downloaded_files = get_downloaded_files()
assert len(downloaded_files) >= 1, "Non-realtime download completed but no file was found."
# Aligned with formats in src/js/config.ts's CONVERSION_FORMATS
@pytest.mark.parametrize("format_name,bitrate,expected_ext", [
("mp3", "320k", ".mp3"),
("aac", "256k", ".m4a"), # AAC is typically in an M4A container
("ogg", "320k", ".ogg"),
("opus", "256k", ".opus"),
("flac", None, ".flac"),
("wav", None, ".wav"),
("alac", None, ".m4a"), # ALAC is also in an M4A container
])
def test_download_with_conversion(base_url, task_waiter, reset_config, format, bitrate):
"""Tests downloading a track with various conversion formats and bitrates."""
print(f"\n--- Testing conversion: {format} @ {bitrate or 'default'} ---")
def test_download_with_conversion_and_verify_format(base_url, task_waiter, reset_config, format_name, bitrate, expected_ext):
"""
Tests downloading a track with various conversion formats and verifies
that the created file has the correct extension.
"""
print(f"\n--- Testing conversion: {format_name.upper()} @ {bitrate or 'default'} ---")
config_payload = {
"service": "spotify",
"fallback": False,
"realTime": True,
"spotifyQuality": "NORMAL",
"convertTo": format,
"convertTo": format_name.upper(),
"bitrate": bitrate
}
requests.post(f"{base_url}/config", json=config_payload)
@@ -125,4 +199,14 @@ def test_download_with_conversion(base_url, task_waiter, reset_config, format, b
task_id = response.json()["task_id"]
final_status = task_waiter(task_id)
assert final_status["status"] == "complete", f"Download failed for format {format} bitrate {bitrate}: {final_status.get('error')}"
assert final_status["status"] == "complete", f"Download failed for format {format_name} bitrate {bitrate}: {final_status.get('error')}"
# Verify that a file with the correct extension was created.
downloaded_files = get_downloaded_files()
assert len(downloaded_files) >= 1, "Conversion download completed but no file was found."
found_correct_format = any(f.lower().endswith(expected_ext) for f in downloaded_files)
assert found_correct_format, (
f"No file with expected extension '{expected_ext}' found for format '{format_name}'. "
f"Found files: {downloaded_files}"
)

View File

@@ -21,7 +21,7 @@ def test_history_logging_and_filtering(base_url, task_waiter, reset_config):
config_payload = {"service": "spotify", "fallback": False, "realTime": True}
requests.post(f"{base_url}/config", json=config_payload)
response = requests.get(f"{base_url}/track/download/{TRACK_ID}")
assert response.status_code == 202
assert response.status_code == 200
task_id = response.json()["task_id"]
task_waiter(task_id) # Wait for the download to complete

View File

@@ -1,93 +0,0 @@
import requests
import pytest
import time
# Use a known, short track for quick tests
TRACK_ID = "1Cts4YV9aOXVAP3bm3Ro6r"
# Use a long playlist to ensure there's time to cancel it
LONG_PLAYLIST_ID = "6WsyUEITURbQXZsqtEewb1" # Today's Top Hits on Spotify
@pytest.fixture
def reset_config(base_url):
"""Fixture to reset the main config after a test."""
response = requests.get(f"{base_url}/config")
original_config = response.json()
yield
requests.post(f"{base_url}/config", json=original_config)
def test_list_tasks(base_url, reset_config):
"""Tests listing all active tasks."""
config_payload = {"service": "spotify", "fallback": False, "realTime": True}
requests.post(f"{base_url}/config", json=config_payload)
# Start a task
response = requests.get(f"{base_url}/track/download/{TRACK_ID}")
assert response.status_code == 202
task_id = response.json()["task_id"]
# Check the list to see if our task appears
response = requests.get(f"{base_url}/prgs/list")
assert response.status_code == 200
tasks = response.json()
assert isinstance(tasks, list)
assert any(t['task_id'] == task_id for t in tasks)
# Clean up by cancelling the task
requests.post(f"{base_url}/prgs/cancel/{task_id}")
def test_get_task_progress_and_log(base_url, task_waiter, reset_config):
"""Tests getting progress for a running task and retrieving its log after completion."""
config_payload = {"service": "spotify", "fallback": False, "realTime": True}
requests.post(f"{base_url}/config", json=config_payload)
response = requests.get(f"{base_url}/track/download/{TRACK_ID}")
assert response.status_code == 202
task_id = response.json()["task_id"]
# Poll progress a few times while it's running to check the endpoint
for _ in range(3):
time.sleep(1)
res = requests.get(f"{base_url}/prgs/{task_id}")
if res.status_code == 200 and res.json():
statuses = res.json()
assert isinstance(statuses, list)
assert "status" in statuses[-1]
break
else:
pytest.fail("Could not get a valid task status in time.")
# Wait for completion
final_status = task_waiter(task_id)
assert final_status["status"] == "complete"
# After completion, check the task log endpoint
res = requests.get(f"{base_url}/prgs/{task_id}?log=true")
assert res.status_code == 200
log_data = res.json()
assert "task_log" in log_data
assert len(log_data["task_log"]) > 0
assert "status" in log_data["task_log"][0]
def test_cancel_task(base_url, reset_config):
"""Tests cancelling a task shortly after it has started."""
config_payload = {"service": "spotify", "fallback": False, "realTime": True}
requests.post(f"{base_url}/config", json=config_payload)
response = requests.get(f"{base_url}/playlist/download/{LONG_PLAYLIST_ID}")
assert response.status_code == 202
task_id = response.json()["task_id"]
# Give it a moment to ensure it has started processing
time.sleep(3)
# Cancel the task
response = requests.post(f"{base_url}/prgs/cancel/{task_id}")
assert response.status_code == 200
assert response.json()["status"] == "cancelled"
# Check the final status to confirm it's marked as cancelled
time.sleep(2) # Allow time for the final status to propagate
res = requests.get(f"{base_url}/prgs/{task_id}")
assert res.status_code == 200
last_status = res.json()[-1]
assert last_status["status"] == "cancelled"