mirror of
https://lavaforge.org/spotizerr/spotizerr.git
synced 2025-12-23 18:29:13 -05:00
test suite
This commit is contained in:
@@ -24,3 +24,4 @@ logs/
|
|||||||
.env
|
.env
|
||||||
.venv
|
.venv
|
||||||
data
|
data
|
||||||
|
tests/
|
||||||
@@ -111,25 +111,25 @@ def handle_download(album_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
json.dumps({"prg_file": task_id}), status=202, mimetype="application/json"
|
json.dumps({"task_id": task_id}), status=202, mimetype="application/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@album_bp.route("/download/cancel", methods=["GET"])
|
@album_bp.route("/download/cancel", methods=["GET"])
|
||||||
def cancel_download():
|
def cancel_download():
|
||||||
"""
|
"""
|
||||||
Cancel a running download process by its prg file name.
|
Cancel a running download process by its task id.
|
||||||
"""
|
"""
|
||||||
prg_file = request.args.get("prg_file")
|
task_id = request.args.get("task_id")
|
||||||
if not prg_file:
|
if not task_id:
|
||||||
return Response(
|
return Response(
|
||||||
json.dumps({"error": "Missing process id (prg_file) parameter"}),
|
json.dumps({"error": "Missing process id (task_id) parameter"}),
|
||||||
status=400,
|
status=400,
|
||||||
mimetype="application/json",
|
mimetype="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use the queue manager's cancellation method.
|
# Use the queue manager's cancellation method.
|
||||||
result = download_queue_manager.cancel_task(prg_file)
|
result = download_queue_manager.cancel_task(task_id)
|
||||||
status_code = 200 if result.get("status") == "cancelled" else 404
|
status_code = 200 if result.get("status") == "cancelled" else 404
|
||||||
|
|
||||||
return Response(json.dumps(result), status=status_code, mimetype="application/json")
|
return Response(json.dumps(result), status=status_code, mimetype="application/json")
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ def handle_download(playlist_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id
|
json.dumps({"task_id": task_id}),
|
||||||
status=202,
|
status=202,
|
||||||
mimetype="application/json",
|
mimetype="application/json",
|
||||||
)
|
)
|
||||||
@@ -142,18 +142,18 @@ def handle_download(playlist_id):
|
|||||||
@playlist_bp.route("/download/cancel", methods=["GET"])
|
@playlist_bp.route("/download/cancel", methods=["GET"])
|
||||||
def cancel_download():
|
def cancel_download():
|
||||||
"""
|
"""
|
||||||
Cancel a running playlist download process by its prg file name.
|
Cancel a running playlist download process by its task id.
|
||||||
"""
|
"""
|
||||||
prg_file = request.args.get("prg_file")
|
task_id = request.args.get("task_id")
|
||||||
if not prg_file:
|
if not task_id:
|
||||||
return Response(
|
return Response(
|
||||||
json.dumps({"error": "Missing process id (prg_file) parameter"}),
|
json.dumps({"error": "Missing task id (task_id) parameter"}),
|
||||||
status=400,
|
status=400,
|
||||||
mimetype="application/json",
|
mimetype="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use the queue manager's cancellation method.
|
# Use the queue manager's cancellation method.
|
||||||
result = download_queue_manager.cancel_task(prg_file)
|
result = download_queue_manager.cancel_task(task_id)
|
||||||
status_code = 200 if result.get("status") == "cancelled" else 404
|
status_code = 200 if result.get("status") == "cancelled" else 404
|
||||||
|
|
||||||
return Response(json.dumps(result), status=status_code, mimetype="application/json")
|
return Response(json.dumps(result), status=status_code, mimetype="application/json")
|
||||||
|
|||||||
@@ -21,16 +21,15 @@ prgs_bp = Blueprint("prgs", __name__, url_prefix="/api/prgs")
|
|||||||
|
|
||||||
|
|
||||||
@prgs_bp.route("/<task_id>", methods=["GET"])
|
@prgs_bp.route("/<task_id>", methods=["GET"])
|
||||||
def get_prg_file(task_id):
|
def get_task_details(task_id):
|
||||||
"""
|
"""
|
||||||
Return a JSON object with the resource type, its name (title),
|
Return a JSON object with the resource type, its name (title),
|
||||||
the last progress update, and, if available, the original request parameters.
|
the last progress update, and, if available, the original request parameters.
|
||||||
|
|
||||||
This function works with both the old PRG file system (for backward compatibility)
|
This function works with the new task ID based system.
|
||||||
and the new task ID based system.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: Either a task UUID from Celery or a PRG filename from the old system
|
task_id: A task UUID from Celery
|
||||||
"""
|
"""
|
||||||
# Only support new task IDs
|
# Only support new task IDs
|
||||||
task_info = get_task_info(task_id)
|
task_info = get_task_info(task_id)
|
||||||
@@ -88,13 +87,12 @@ def get_prg_file(task_id):
|
|||||||
|
|
||||||
|
|
||||||
@prgs_bp.route("/delete/<task_id>", methods=["DELETE"])
|
@prgs_bp.route("/delete/<task_id>", methods=["DELETE"])
|
||||||
def delete_prg_file(task_id):
|
def delete_task(task_id):
|
||||||
"""
|
"""
|
||||||
Delete a task's information and history.
|
Delete a task's information and history.
|
||||||
Works with both the old PRG file system and the new task ID based system.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: Either a task UUID from Celery or a PRG filename from the old system
|
task_id: A task UUID from Celery
|
||||||
"""
|
"""
|
||||||
# Only support new task IDs
|
# Only support new task IDs
|
||||||
task_info = get_task_info(task_id)
|
task_info = get_task_info(task_id)
|
||||||
@@ -107,7 +105,7 @@ def delete_prg_file(task_id):
|
|||||||
|
|
||||||
|
|
||||||
@prgs_bp.route("/list", methods=["GET"])
|
@prgs_bp.route("/list", methods=["GET"])
|
||||||
def list_prg_files():
|
def list_tasks():
|
||||||
"""
|
"""
|
||||||
Retrieve a list of all tasks in the system.
|
Retrieve a list of all tasks in the system.
|
||||||
Returns a detailed list of task objects including status and metadata.
|
Returns a detailed list of task objects including status and metadata.
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ def handle_download(track_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id
|
json.dumps({"task_id": task_id}),
|
||||||
status=202,
|
status=202,
|
||||||
mimetype="application/json",
|
mimetype="application/json",
|
||||||
)
|
)
|
||||||
@@ -136,18 +136,18 @@ def handle_download(track_id):
|
|||||||
@track_bp.route("/download/cancel", methods=["GET"])
|
@track_bp.route("/download/cancel", methods=["GET"])
|
||||||
def cancel_download():
|
def cancel_download():
|
||||||
"""
|
"""
|
||||||
Cancel a running track download process by its process id (prg file name).
|
Cancel a running track download process by its task id.
|
||||||
"""
|
"""
|
||||||
prg_file = request.args.get("prg_file")
|
task_id = request.args.get("task_id")
|
||||||
if not prg_file:
|
if not task_id:
|
||||||
return Response(
|
return Response(
|
||||||
json.dumps({"error": "Missing process id (prg_file) parameter"}),
|
json.dumps({"error": "Missing task id (task_id) parameter"}),
|
||||||
status=400,
|
status=400,
|
||||||
mimetype="application/json",
|
mimetype="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use the queue manager's cancellation method.
|
# Use the queue manager's cancellation method.
|
||||||
result = download_queue_manager.cancel_task(prg_file)
|
result = download_queue_manager.cancel_task(task_id)
|
||||||
status_code = 200 if result.get("status") == "cancelled" else 404
|
status_code = 200 if result.get("status") == "cancelled" else 404
|
||||||
|
|
||||||
return Response(json.dumps(result), status=status_code, mimetype="application/json")
|
return Response(json.dumps(result), status=status_code, mimetype="application/json")
|
||||||
|
|||||||
202
src/js/queue.ts
202
src/js/queue.ts
@@ -71,7 +71,7 @@ interface StatusData {
|
|||||||
retry_count?: number;
|
retry_count?: number;
|
||||||
max_retries?: number; // from config potentially
|
max_retries?: number; // from config potentially
|
||||||
seconds_left?: number;
|
seconds_left?: number;
|
||||||
prg_file?: string;
|
task_id?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
reason?: string; // for skipped
|
reason?: string; // for skipped
|
||||||
parent?: ParentInfo;
|
parent?: ParentInfo;
|
||||||
@@ -100,7 +100,7 @@ interface StatusData {
|
|||||||
interface QueueEntry {
|
interface QueueEntry {
|
||||||
item: QueueItem;
|
item: QueueItem;
|
||||||
type: string;
|
type: string;
|
||||||
prgFile: string;
|
taskId: string;
|
||||||
requestUrl: string | null;
|
requestUrl: string | null;
|
||||||
element: HTMLElement;
|
element: HTMLElement;
|
||||||
lastStatus: StatusData;
|
lastStatus: StatusData;
|
||||||
@@ -196,13 +196,13 @@ export class DownloadQueue {
|
|||||||
// const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount");
|
// const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount");
|
||||||
// this.visibleCount = storedVisibleCount ? parseInt(storedVisibleCount, 10) : 10;
|
// this.visibleCount = storedVisibleCount ? parseInt(storedVisibleCount, 10) : 10;
|
||||||
|
|
||||||
// Load the cached status info (object keyed by prgFile) - This is also redundant
|
// Load the cached status info (object keyed by taskId) - This is also redundant
|
||||||
// this.queueCache = JSON.parse(localStorage.getItem("downloadQueueCache") || "{}");
|
// this.queueCache = JSON.parse(localStorage.getItem("downloadQueueCache") || "{}");
|
||||||
|
|
||||||
// Wait for initDOM to complete before setting up event listeners and loading existing PRG files.
|
// Wait for initDOM to complete before setting up event listeners and loading existing PRG files.
|
||||||
this.initDOM().then(() => {
|
this.initDOM().then(() => {
|
||||||
this.initEventListeners();
|
this.initEventListeners();
|
||||||
this.loadExistingPrgFiles();
|
this.loadExistingTasks();
|
||||||
// Start periodic sync
|
// Start periodic sync
|
||||||
setInterval(() => this.periodicSyncWithServer(), 10000); // Sync every 10 seconds
|
setInterval(() => this.periodicSyncWithServer(), 10000); // Sync every 10 seconds
|
||||||
});
|
});
|
||||||
@@ -278,8 +278,8 @@ export class DownloadQueue {
|
|||||||
cancelAllBtn.addEventListener('click', () => {
|
cancelAllBtn.addEventListener('click', () => {
|
||||||
for (const queueId in this.queueEntries) {
|
for (const queueId in this.queueEntries) {
|
||||||
const entry = this.queueEntries[queueId];
|
const entry = this.queueEntries[queueId];
|
||||||
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
if (entry && !entry.hasEnded && entry.prgFile) {
|
if (entry && !entry.hasEnded && entry.taskId) {
|
||||||
// Mark as cancelling visually
|
// Mark as cancelling visually
|
||||||
if (entry.element) {
|
if (entry.element) {
|
||||||
entry.element.classList.add('cancelling');
|
entry.element.classList.add('cancelling');
|
||||||
@@ -289,7 +289,7 @@ export class DownloadQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cancel each active download
|
// Cancel each active download
|
||||||
fetch(`/api/${entry.type}/download/cancel?prg_file=${entry.prgFile}`)
|
fetch(`/api/${entry.type}/download/cancel?task_id=${entry.taskId}`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// API returns status 'cancelled' when cancellation succeeds
|
// API returns status 'cancelled' when cancellation succeeds
|
||||||
@@ -388,9 +388,9 @@ export class DownloadQueue {
|
|||||||
/**
|
/**
|
||||||
* Adds a new download entry.
|
* Adds a new download entry.
|
||||||
*/
|
*/
|
||||||
addDownload(item: QueueItem, type: string, prgFile: string, requestUrl: string | null = null, startMonitoring: boolean = false): string {
|
addDownload(item: QueueItem, type: string, taskId: string, requestUrl: string | null = null, startMonitoring: boolean = false): string {
|
||||||
const queueId = this.generateQueueId();
|
const queueId = this.generateQueueId();
|
||||||
const entry = this.createQueueEntry(item, type, prgFile, queueId, requestUrl);
|
const entry = this.createQueueEntry(item, type, taskId, queueId, requestUrl);
|
||||||
this.queueEntries[queueId] = entry;
|
this.queueEntries[queueId] = entry;
|
||||||
// Re-render and update which entries are processed.
|
// Re-render and update which entries are processed.
|
||||||
this.updateQueueOrder();
|
this.updateQueueOrder();
|
||||||
@@ -417,17 +417,17 @@ export class DownloadQueue {
|
|||||||
|
|
||||||
// Show a preparing message for new entries
|
// Show a preparing message for new entries
|
||||||
if (entry.isNew) {
|
if (entry.isNew) {
|
||||||
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
if (logElement) {
|
if (logElement) {
|
||||||
logElement.textContent = "Initializing download...";
|
logElement.textContent = "Initializing download...";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Starting monitoring for ${entry.type} with PRG file: ${entry.prgFile}`);
|
console.log(`Starting monitoring for ${entry.type} with task ID: ${entry.taskId}`);
|
||||||
|
|
||||||
// For backward compatibility, first try to get initial status from the REST API
|
// For backward compatibility, first try to get initial status from the REST API
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/prgs/${entry.prgFile}`);
|
const response = await fetch(`/api/prgs/${entry.taskId}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data: StatusData = await response.json(); // Add type to data
|
const data: StatusData = await response.json(); // Add type to data
|
||||||
|
|
||||||
@@ -464,7 +464,7 @@ export class DownloadQueue {
|
|||||||
entry.status = data.last_line.status || 'unknown'; // Ensure status is not undefined
|
entry.status = data.last_line.status || 'unknown'; // Ensure status is not undefined
|
||||||
|
|
||||||
// Update status message without recreating the element
|
// Update status message without recreating the element
|
||||||
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
if (logElement) {
|
if (logElement) {
|
||||||
const statusMessage = this.getStatusMessage(data.last_line);
|
const statusMessage = this.getStatusMessage(data.last_line);
|
||||||
logElement.textContent = statusMessage;
|
logElement.textContent = statusMessage;
|
||||||
@@ -474,7 +474,7 @@ export class DownloadQueue {
|
|||||||
this.applyStatusClasses(entry, data.last_line);
|
this.applyStatusClasses(entry, data.last_line);
|
||||||
|
|
||||||
// Save updated status to cache, ensuring we preserve parent data
|
// Save updated status to cache, ensuring we preserve parent data
|
||||||
this.queueCache[entry.prgFile] = {
|
this.queueCache[entry.taskId] = {
|
||||||
...data.last_line,
|
...data.last_line,
|
||||||
// Ensure parent data is preserved
|
// Ensure parent data is preserved
|
||||||
parent: data.last_line.parent || entry.lastStatus?.parent
|
parent: data.last_line.parent || entry.lastStatus?.parent
|
||||||
@@ -540,11 +540,11 @@ export class DownloadQueue {
|
|||||||
/**
|
/**
|
||||||
* Creates a new queue entry. It checks localStorage for any cached info.
|
* Creates a new queue entry. It checks localStorage for any cached info.
|
||||||
*/
|
*/
|
||||||
createQueueEntry(item: QueueItem, type: string, prgFile: string, queueId: string, requestUrl: string | null): QueueEntry {
|
createQueueEntry(item: QueueItem, type: string, taskId: string, queueId: string, requestUrl: string | null): QueueEntry {
|
||||||
console.log(`Creating queue entry with initial type: ${type}`);
|
console.log(`Creating queue entry with initial type: ${type}`);
|
||||||
|
|
||||||
// Get cached data if it exists
|
// Get cached data if it exists
|
||||||
const cachedData: StatusData | undefined = this.queueCache[prgFile]; // Add type
|
const cachedData: StatusData | undefined = this.queueCache[taskId]; // Add type
|
||||||
|
|
||||||
// If we have cached data, use it to determine the true type and item properties
|
// If we have cached data, use it to determine the true type and item properties
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
@@ -588,9 +588,9 @@ export class DownloadQueue {
|
|||||||
const entry: QueueEntry = { // Add type to entry
|
const entry: QueueEntry = { // Add type to entry
|
||||||
item,
|
item,
|
||||||
type,
|
type,
|
||||||
prgFile,
|
taskId,
|
||||||
requestUrl, // for potential retry
|
requestUrl, // for potential retry
|
||||||
element: this.createQueueItem(item, type, prgFile, queueId),
|
element: this.createQueueItem(item, type, taskId, queueId),
|
||||||
lastStatus: {
|
lastStatus: {
|
||||||
// Initialize with basic item metadata for immediate display
|
// Initialize with basic item metadata for immediate display
|
||||||
type,
|
type,
|
||||||
@@ -615,7 +615,7 @@ export class DownloadQueue {
|
|||||||
realTimeStallDetector: { count: 0, lastStatusJson: '' } // For detecting stalled real_time downloads
|
realTimeStallDetector: { count: 0, lastStatusJson: '' } // For detecting stalled real_time downloads
|
||||||
};
|
};
|
||||||
|
|
||||||
// If cached info exists for this PRG file, use it.
|
// If cached info exists for this task, use it.
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
entry.lastStatus = cachedData;
|
entry.lastStatus = cachedData;
|
||||||
const logEl = entry.element.querySelector('.log') as HTMLElement | null;
|
const logEl = entry.element.querySelector('.log') as HTMLElement | null;
|
||||||
@@ -640,7 +640,7 @@ export class DownloadQueue {
|
|||||||
/**
|
/**
|
||||||
* Returns an HTML element for the queue entry with modern UI styling.
|
* Returns an HTML element for the queue entry with modern UI styling.
|
||||||
*/
|
*/
|
||||||
createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string): HTMLElement {
|
createQueueItem(item: QueueItem, type: string, taskId: string, queueId:string): HTMLElement {
|
||||||
// Track whether this is a multi-track item (album or playlist)
|
// Track whether this is a multi-track item (album or playlist)
|
||||||
const isMultiTrack = type === 'album' || type === 'playlist';
|
const isMultiTrack = type === 'album' || type === 'playlist';
|
||||||
const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...';
|
const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...';
|
||||||
@@ -664,26 +664,26 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
${displayArtist ? `<div class="artist">${displayArtist}</div>` : ''}
|
${displayArtist ? `<div class="artist">${displayArtist}</div>` : ''}
|
||||||
<div class="type ${type}">${displayType}</div>
|
<div class="type ${type}">${displayType}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="cancel-btn" data-prg="${prgFile}" data-type="${type}" data-queueid="${queueId}" title="Cancel Download">
|
<button class="cancel-btn" data-taskid="${taskId}" data-type="${type}" data-queueid="${queueId}" title="Cancel Download">
|
||||||
<img src="/static/images/skull-head.svg" alt="Cancel Download" style="width: 16px; height: 16px;">
|
<img src="/static/images/skull-head.svg" alt="Cancel Download" style="width: 16px; height: 16px;">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="queue-item-status">
|
<div class="queue-item-status">
|
||||||
<div class="log" id="log-${queueId}-${prgFile}">${defaultMessage}</div>
|
<div class="log" id="log-${queueId}-${taskId}">${defaultMessage}</div>
|
||||||
|
|
||||||
<!-- Error details container (hidden by default) -->
|
<!-- Error details container (hidden by default) -->
|
||||||
<div class="error-details" id="error-details-${queueId}-${prgFile}" style="display: none;"></div>
|
<div class="error-details" id="error-details-${queueId}-${taskId}" style="display: none;"></div>
|
||||||
|
|
||||||
<div class="progress-container">
|
<div class="progress-container">
|
||||||
<!-- Track-level progress bar for single track or current track in multi-track items -->
|
<!-- Track-level progress bar for single track or current track in multi-track items -->
|
||||||
<div class="track-progress-bar-container" id="track-progress-container-${queueId}-${prgFile}">
|
<div class="track-progress-bar-container" id="track-progress-container-${queueId}-${taskId}">
|
||||||
<div class="track-progress-bar" id="track-progress-bar-${queueId}-${prgFile}"
|
<div class="track-progress-bar" id="track-progress-bar-${queueId}-${taskId}"
|
||||||
role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%;"></div>
|
role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Time elapsed for real-time downloads -->
|
<!-- Time elapsed for real-time downloads -->
|
||||||
<div class="time-elapsed" id="time-elapsed-${queueId}-${prgFile}"></div>
|
<div class="time-elapsed" id="time-elapsed-${queueId}-${taskId}"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
@@ -693,10 +693,10 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
<div class="overall-progress-container">
|
<div class="overall-progress-container">
|
||||||
<div class="overall-progress-header">
|
<div class="overall-progress-header">
|
||||||
<span class="overall-progress-label">Overall Progress</span>
|
<span class="overall-progress-label">Overall Progress</span>
|
||||||
<span class="overall-progress-count" id="progress-count-${queueId}-${prgFile}">0/0</span>
|
<span class="overall-progress-count" id="progress-count-${queueId}-${taskId}">0/0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="overall-progress-bar-container">
|
<div class="overall-progress-bar-container">
|
||||||
<div class="overall-progress-bar" id="overall-bar-${queueId}-${prgFile}"
|
<div class="overall-progress-bar" id="overall-bar-${queueId}-${taskId}"
|
||||||
role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%;"></div>
|
role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -745,7 +745,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
case 'error':
|
case 'error':
|
||||||
entry.element.classList.add('error');
|
entry.element.classList.add('error');
|
||||||
// Hide error-details to prevent duplicate error display
|
// Hide error-details to prevent duplicate error display
|
||||||
const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
if (errorDetailsContainer) {
|
if (errorDetailsContainer) {
|
||||||
errorDetailsContainer.style.display = 'none';
|
errorDetailsContainer.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -755,7 +755,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
entry.element.classList.add('complete');
|
entry.element.classList.add('complete');
|
||||||
// Hide error details if present
|
// Hide error details if present
|
||||||
if (entry.element) {
|
if (entry.element) {
|
||||||
const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
if (errorDetailsContainer) {
|
if (errorDetailsContainer) {
|
||||||
errorDetailsContainer.style.display = 'none';
|
errorDetailsContainer.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -765,7 +765,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
entry.element.classList.add('cancelled');
|
entry.element.classList.add('cancelled');
|
||||||
// Hide error details if present
|
// Hide error details if present
|
||||||
if (entry.element) {
|
if (entry.element) {
|
||||||
const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
if (errorDetailsContainer) {
|
if (errorDetailsContainer) {
|
||||||
errorDetailsContainer.style.display = 'none';
|
errorDetailsContainer.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -778,8 +778,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
const btn = (e.target as HTMLElement).closest('button') as HTMLButtonElement | null; // Add types and null check
|
const btn = (e.target as HTMLElement).closest('button') as HTMLButtonElement | null; // Add types and null check
|
||||||
if (!btn) return; // Guard clause
|
if (!btn) return; // Guard clause
|
||||||
btn.style.display = 'none';
|
btn.style.display = 'none';
|
||||||
const { prg, type, queueid } = btn.dataset;
|
const { taskid, type, queueid } = btn.dataset;
|
||||||
if (!prg || !type || !queueid) return; // Guard against undefined dataset properties
|
if (!taskid || !type || !queueid) return; // Guard against undefined dataset properties
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the queue item element
|
// Get the queue item element
|
||||||
@@ -790,13 +790,13 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show cancellation in progress
|
// Show cancellation in progress
|
||||||
const logElement = document.getElementById(`log-${queueid}-${prg}`) as HTMLElement | null;
|
const logElement = document.getElementById(`log-${queueid}-${taskid}`) as HTMLElement | null;
|
||||||
if (logElement) {
|
if (logElement) {
|
||||||
logElement.textContent = "Cancelling...";
|
logElement.textContent = "Cancelling...";
|
||||||
}
|
}
|
||||||
|
|
||||||
// First cancel the download
|
// First cancel the download
|
||||||
const response = await fetch(`/api/${type}/download/cancel?prg_file=${prg}`);
|
const response = await fetch(`/api/${type}/download/cancel?task_id=${taskid}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
// API returns status 'cancelled' when cancellation succeeds
|
// API returns status 'cancelled' when cancellation succeeds
|
||||||
if (data.status === "cancelled" || data.status === "cancel") {
|
if (data.status === "cancelled" || data.status === "cancel") {
|
||||||
@@ -813,7 +813,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
|
|
||||||
// Mark as cancelled in the cache to prevent re-loading on page refresh
|
// Mark as cancelled in the cache to prevent re-loading on page refresh
|
||||||
entry.status = "cancelled";
|
entry.status = "cancelled";
|
||||||
this.queueCache[prg] = { status: "cancelled" };
|
this.queueCache[taskid] = { status: "cancelled" };
|
||||||
localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache));
|
localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache));
|
||||||
|
|
||||||
// Immediately remove the item from the UI
|
// Immediately remove the item from the UI
|
||||||
@@ -924,7 +924,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
// This is important for items that become visible after "Show More" or other UI changes
|
// This is important for items that become visible after "Show More" or other UI changes
|
||||||
Object.values(this.queueEntries).forEach(entry => {
|
Object.values(this.queueEntries).forEach(entry => {
|
||||||
if (this.isEntryVisible(entry.uniqueId) && !entry.hasEnded && !this.pollingIntervals[entry.uniqueId]) {
|
if (this.isEntryVisible(entry.uniqueId) && !entry.hasEnded && !this.pollingIntervals[entry.uniqueId]) {
|
||||||
console.log(`updateQueueOrder: Ensuring polling for visible/active entry ${entry.uniqueId} (${entry.prgFile})`);
|
console.log(`updateQueueOrder: Ensuring polling for visible/active entry ${entry.uniqueId} (${entry.taskId})`);
|
||||||
this.setupPollingInterval(entry.uniqueId);
|
this.setupPollingInterval(entry.uniqueId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -995,8 +995,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
delete this.queueEntries[queueId];
|
delete this.queueEntries[queueId];
|
||||||
|
|
||||||
// Remove the cached info
|
// Remove the cached info
|
||||||
if (this.queueCache[entry.prgFile]) {
|
if (this.queueCache[entry.taskId]) {
|
||||||
delete this.queueCache[entry.prgFile];
|
delete this.queueCache[entry.taskId];
|
||||||
localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache));
|
localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,13 +1025,13 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
|
|
||||||
// Find the queue item this status belongs to
|
// Find the queue item this status belongs to
|
||||||
let queueItem: QueueEntry | null = null;
|
let queueItem: QueueEntry | null = null;
|
||||||
const prgFile = data.prg_file || Object.keys(this.queueCache).find(key =>
|
const taskId = data.task_id || Object.keys(this.queueCache).find(key =>
|
||||||
this.queueCache[key].status === data.status && this.queueCache[key].type === data.type
|
this.queueCache[key].status === data.status && this.queueCache[key].type === data.type
|
||||||
);
|
);
|
||||||
|
|
||||||
if (prgFile) {
|
if (taskId) {
|
||||||
const queueId = Object.keys(this.queueEntries).find(id =>
|
const queueId = Object.keys(this.queueEntries).find(id =>
|
||||||
this.queueEntries[id].prgFile === prgFile
|
this.queueEntries[id].taskId === taskId
|
||||||
);
|
);
|
||||||
if (queueId) {
|
if (queueId) {
|
||||||
queueItem = this.queueEntries[queueId];
|
queueItem = this.queueEntries[queueId];
|
||||||
@@ -1408,17 +1408,17 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
|
|
||||||
const retryData: StatusData = await retryResponse.json(); // Add type
|
const retryData: StatusData = await retryResponse.json(); // Add type
|
||||||
|
|
||||||
if (retryData.prg_file) {
|
if (retryData.task_id) {
|
||||||
const newPrgFile = retryData.prg_file;
|
const newTaskId = retryData.task_id;
|
||||||
|
|
||||||
// Clean up the old entry from UI, memory, cache, and server (PRG file)
|
// Clean up the old entry from UI, memory, cache, and server (task file)
|
||||||
// logElement and retryBtn are part of the old entry's DOM structure and will be removed.
|
// logElement and retryBtn are part of the old entry's DOM structure and will be removed.
|
||||||
await this.cleanupEntry(queueId);
|
await this.cleanupEntry(queueId);
|
||||||
|
|
||||||
// Add the new download entry. This will create a new element, start monitoring, etc.
|
// Add the new download entry. This will create a new element, start monitoring, etc.
|
||||||
this.addDownload(originalItem, apiTypeForNewEntry, newPrgFile, requestUrlForNewEntry, true);
|
this.addDownload(originalItem, apiTypeForNewEntry, newTaskId, requestUrlForNewEntry, true);
|
||||||
|
|
||||||
// The old setTimeout block for deleting oldPrgFile is no longer needed as cleanupEntry handles it.
|
// The old setTimeout block for deleting old task file is no longer needed as cleanupEntry handles it.
|
||||||
} else {
|
} else {
|
||||||
if (errorMessageDiv) errorMessageDiv.textContent = 'Retry failed: invalid response from server.';
|
if (errorMessageDiv) errorMessageDiv.textContent = 'Retry failed: invalid response from server.';
|
||||||
const currentEntry = this.queueEntries[queueId]; // Check if old entry still exists
|
const currentEntry = this.queueEntries[queueId]; // Check if old entry still exists
|
||||||
@@ -1574,8 +1574,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
// Make queue visible
|
// Make queue visible
|
||||||
this.toggleVisibility(true);
|
this.toggleVisibility(true);
|
||||||
|
|
||||||
// Just load existing PRG files as a fallback
|
// Just load existing task files as a fallback
|
||||||
await this.loadExistingPrgFiles();
|
await this.loadExistingTasks();
|
||||||
|
|
||||||
// Force start monitoring for all loaded entries
|
// Force start monitoring for all loaded entries
|
||||||
for (const queueId in this.queueEntries) {
|
for (const queueId in this.queueEntries) {
|
||||||
@@ -1590,12 +1590,12 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle single-file downloads (tracks, albums, playlists)
|
// Handle single-file downloads (tracks, albums, playlists)
|
||||||
if ('prg_file' in data && data.prg_file) { // Type guard
|
if ('task_id' in data && data.task_id) { // Type guard
|
||||||
console.log(`Adding ${type} PRG file: ${data.prg_file}`);
|
console.log(`Adding ${type} task with ID: ${data.task_id}`);
|
||||||
|
|
||||||
// Store the initial metadata in the cache so it's available
|
// Store the initial metadata in the cache so it's available
|
||||||
// even before the first status update
|
// even before the first status update
|
||||||
this.queueCache[data.prg_file] = {
|
this.queueCache[data.task_id] = {
|
||||||
type,
|
type,
|
||||||
status: 'initializing',
|
status: 'initializing',
|
||||||
name: item.name || 'Unknown',
|
name: item.name || 'Unknown',
|
||||||
@@ -1606,7 +1606,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Use direct monitoring for all downloads for consistency
|
// Use direct monitoring for all downloads for consistency
|
||||||
const queueId = this.addDownload(item, type, data.prg_file, apiUrl, true);
|
const queueId = this.addDownload(item, type, data.task_id, apiUrl, true);
|
||||||
|
|
||||||
// Make queue visible to show progress if not already visible
|
// Make queue visible to show progress if not already visible
|
||||||
if (this.config && !this.config.downloadQueueVisible) { // Add null check for config
|
if (this.config && !this.config.downloadQueueVisible) { // Add null check for config
|
||||||
@@ -1624,9 +1624,9 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads existing PRG files from the /api/prgs/list endpoint and adds them as queue entries.
|
* Loads existing task files from the /api/prgs/list endpoint and adds them as queue entries.
|
||||||
*/
|
*/
|
||||||
async loadExistingPrgFiles() {
|
async loadExistingTasks() {
|
||||||
try {
|
try {
|
||||||
// Clear existing queue entries first to avoid duplicates when refreshing
|
// Clear existing queue entries first to avoid duplicates when refreshing
|
||||||
for (const queueId in this.queueEntries) {
|
for (const queueId in this.queueEntries) {
|
||||||
@@ -1646,23 +1646,23 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
const terminalStates = ['complete', 'done', 'cancelled', 'ERROR_AUTO_CLEANED', 'ERROR_RETRIED', 'cancel', 'interrupted', 'error'];
|
const terminalStates = ['complete', 'done', 'cancelled', 'ERROR_AUTO_CLEANED', 'ERROR_RETRIED', 'cancel', 'interrupted', 'error'];
|
||||||
|
|
||||||
for (const taskData of existingTasks) {
|
for (const taskData of existingTasks) {
|
||||||
const prgFile = taskData.task_id; // Use task_id as prgFile identifier
|
const taskId = taskData.task_id; // Use task_id as taskId identifier
|
||||||
const lastStatus = taskData.last_status_obj;
|
const lastStatus = taskData.last_status_obj;
|
||||||
const originalRequest = taskData.original_request || {};
|
const originalRequest = taskData.original_request || {};
|
||||||
|
|
||||||
// Skip adding to UI if the task is already in a terminal state
|
// Skip adding to UI if the task is already in a terminal state
|
||||||
if (lastStatus && terminalStates.includes(lastStatus.status)) {
|
if (lastStatus && terminalStates.includes(lastStatus.status)) {
|
||||||
console.log(`Skipping UI addition for terminal task ${prgFile}, status: ${lastStatus.status}`);
|
console.log(`Skipping UI addition for terminal task ${taskId}, status: ${lastStatus.status}`);
|
||||||
// Also ensure it's cleaned from local cache if it was there
|
// Also ensure it's cleaned from local cache if it was there
|
||||||
if (this.queueCache[prgFile]) {
|
if (this.queueCache[taskId]) {
|
||||||
delete this.queueCache[prgFile];
|
delete this.queueCache[taskId];
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let itemType = taskData.type || originalRequest.type || 'unknown';
|
let itemType = taskData.type || originalRequest.type || 'unknown';
|
||||||
let dummyItem: QueueItem = {
|
let dummyItem: QueueItem = {
|
||||||
name: taskData.name || originalRequest.name || prgFile,
|
name: taskData.name || originalRequest.name || taskId,
|
||||||
artist: taskData.artist || originalRequest.artist || '',
|
artist: taskData.artist || originalRequest.artist || '',
|
||||||
type: itemType,
|
type: itemType,
|
||||||
url: originalRequest.url || lastStatus?.url || '',
|
url: originalRequest.url || lastStatus?.url || '',
|
||||||
@@ -1680,29 +1680,25 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
dummyItem = {
|
dummyItem = {
|
||||||
name: parent.title || 'Unknown Album',
|
name: parent.title || 'Unknown Album',
|
||||||
artist: parent.artist || 'Unknown Artist',
|
artist: parent.artist || 'Unknown Artist',
|
||||||
type: 'album',
|
type: 'album', url: parent.url || '',
|
||||||
url: parent.url || '',
|
|
||||||
total_tracks: parent.total_tracks || lastStatus.total_tracks,
|
total_tracks: parent.total_tracks || lastStatus.total_tracks,
|
||||||
parent: parent
|
parent: parent };
|
||||||
};
|
|
||||||
} else if (parent.type === 'playlist') {
|
} else if (parent.type === 'playlist') {
|
||||||
itemType = 'playlist';
|
itemType = 'playlist';
|
||||||
dummyItem = {
|
dummyItem = {
|
||||||
name: parent.name || 'Unknown Playlist',
|
name: parent.name || 'Unknown Playlist',
|
||||||
owner: parent.owner || 'Unknown Creator',
|
owner: parent.owner || 'Unknown Creator',
|
||||||
type: 'playlist',
|
type: 'playlist', url: parent.url || '',
|
||||||
url: parent.url || '',
|
|
||||||
total_tracks: parent.total_tracks || lastStatus.total_tracks,
|
total_tracks: parent.total_tracks || lastStatus.total_tracks,
|
||||||
parent: parent
|
parent: parent };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
if (lastStatus && lastStatus.retry_count) {
|
if (lastStatus && lastStatus.retry_count) {
|
||||||
retryCount = lastStatus.retry_count;
|
retryCount = lastStatus.retry_count;
|
||||||
} else if (prgFile.includes('_retry')) {
|
} else if (taskId.includes('_retry')) {
|
||||||
const retryMatch = prgFile.match(/_retry(\d+)/);
|
const retryMatch = taskId.match(/_retry(\d+)/);
|
||||||
if (retryMatch && retryMatch[1]) {
|
if (retryMatch && retryMatch[1]) {
|
||||||
retryCount = parseInt(retryMatch[1], 10);
|
retryCount = parseInt(retryMatch[1], 10);
|
||||||
}
|
}
|
||||||
@@ -1711,7 +1707,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
const requestUrl = originalRequest.url ? `/api/${itemType}/download/${originalRequest.url.split('/').pop()}?name=${encodeURIComponent(dummyItem.name || '')}&artist=${encodeURIComponent(dummyItem.artist || '')}` : null;
|
const requestUrl = originalRequest.url ? `/api/${itemType}/download/${originalRequest.url.split('/').pop()}?name=${encodeURIComponent(dummyItem.name || '')}&artist=${encodeURIComponent(dummyItem.artist || '')}` : null;
|
||||||
|
|
||||||
const queueId = this.generateQueueId();
|
const queueId = this.generateQueueId();
|
||||||
const entry = this.createQueueEntry(dummyItem, itemType, prgFile, queueId, requestUrl);
|
const entry = this.createQueueEntry(dummyItem, itemType, taskId, queueId, requestUrl);
|
||||||
entry.retryCount = retryCount;
|
entry.retryCount = retryCount;
|
||||||
|
|
||||||
if (lastStatus) {
|
if (lastStatus) {
|
||||||
@@ -1719,7 +1715,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
if (lastStatus.parent) {
|
if (lastStatus.parent) {
|
||||||
entry.parentInfo = lastStatus.parent;
|
entry.parentInfo = lastStatus.parent;
|
||||||
}
|
}
|
||||||
this.queueCache[prgFile] = lastStatus; // Cache the last known status
|
this.queueCache[taskId] = lastStatus; // Cache the last known status
|
||||||
this.applyStatusClasses(entry, lastStatus);
|
this.applyStatusClasses(entry, lastStatus);
|
||||||
|
|
||||||
const logElement = entry.element.querySelector('.log') as HTMLElement | null;
|
const logElement = entry.element.querySelector('.log') as HTMLElement | null;
|
||||||
@@ -1734,7 +1730,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
this.updateQueueOrder();
|
this.updateQueueOrder();
|
||||||
this.startMonitoringActiveEntries();
|
this.startMonitoringActiveEntries();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading existing PRG files:", error);
|
console.error("Error loading existing task files:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1792,8 +1788,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
setupPollingInterval(queueId: string) { // Add type
|
setupPollingInterval(queueId: string) { // Add type
|
||||||
console.log(`Setting up polling for ${queueId}`);
|
console.log(`Setting up polling for ${queueId}`);
|
||||||
const entry = this.queueEntries[queueId];
|
const entry = this.queueEntries[queueId];
|
||||||
if (!entry || !entry.prgFile) {
|
if (!entry || !entry.taskId) {
|
||||||
console.warn(`No entry or prgFile for ${queueId}`);
|
console.warn(`No entry or taskId for ${queueId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1813,7 +1809,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
this.pollingIntervals[queueId] = intervalId as unknown as number; // Cast to number via unknown
|
this.pollingIntervals[queueId] = intervalId as unknown as number; // Cast to number via unknown
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error creating polling for ${queueId}:`, error);
|
console.error(`Error creating polling for ${queueId}:`, error);
|
||||||
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
if (logElement) {
|
if (logElement) {
|
||||||
logElement.textContent = `Error with download: ${(error as Error).message}`; // Cast to Error
|
logElement.textContent = `Error with download: ${(error as Error).message}`; // Cast to Error
|
||||||
entry.element.classList.add('error');
|
entry.element.classList.add('error');
|
||||||
@@ -1823,13 +1819,13 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
|
|
||||||
async fetchDownloadStatus(queueId: string) { // Add type
|
async fetchDownloadStatus(queueId: string) { // Add type
|
||||||
const entry = this.queueEntries[queueId];
|
const entry = this.queueEntries[queueId];
|
||||||
if (!entry || !entry.prgFile) {
|
if (!entry || !entry.taskId) {
|
||||||
console.warn(`No entry or prgFile for ${queueId}`);
|
console.warn(`No entry or taskId for ${queueId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/prgs/${entry.prgFile}`);
|
const response = await fetch(`/api/prgs/${entry.taskId}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error: ${response.status}`);
|
throw new Error(`HTTP error: ${response.status}`);
|
||||||
}
|
}
|
||||||
@@ -1929,7 +1925,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
console.error(`Error fetching status for ${queueId}:`, error);
|
console.error(`Error fetching status for ${queueId}:`, error);
|
||||||
|
|
||||||
// Show error in log
|
// Show error in log
|
||||||
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
if (logElement) {
|
if (logElement) {
|
||||||
logElement.textContent = `Error updating status: ${(error as Error).message}`; // Cast to Error
|
logElement.textContent = `Error updating status: ${(error as Error).message}`; // Cast to Error
|
||||||
}
|
}
|
||||||
@@ -2010,7 +2006,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
|
|
||||||
const STALL_THRESHOLD = 600; // Approx 5 minutes (600 polls * 0.5s/poll)
|
const STALL_THRESHOLD = 600; // Approx 5 minutes (600 polls * 0.5s/poll)
|
||||||
if (detector.count >= STALL_THRESHOLD) {
|
if (detector.count >= STALL_THRESHOLD) {
|
||||||
console.warn(`Download ${queueId} (${entry.prgFile}) appears stalled in real_time state. Metrics: ${detector.lastStatusJson}. Stall count: ${detector.count}. Forcing error.`);
|
console.warn(`Download ${queueId} (${entry.taskId}) appears stalled in real_time state. Metrics: ${detector.lastStatusJson}. Stall count: ${detector.count}. Forcing error.`);
|
||||||
statusData.status = 'error';
|
statusData.status = 'error';
|
||||||
statusData.error = 'Download stalled (no progress updates for 5 minutes)';
|
statusData.error = 'Download stalled (no progress updates for 5 minutes)';
|
||||||
statusData.can_retry = true; // Allow manual retry for stalled items
|
statusData.can_retry = true; // Allow manual retry for stalled items
|
||||||
@@ -2045,7 +2041,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
|
|
||||||
// Update log message - but only if we're not handling a track update for an album/playlist
|
// Update log message - but only if we're not handling a track update for an album/playlist
|
||||||
// That case is handled separately in updateItemMetadata to ensure we show the right track info
|
// That case is handled separately in updateItemMetadata to ensure we show the right track info
|
||||||
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
if (logElement && status !== 'error' && !(statusData.type === 'track' && statusData.parent &&
|
if (logElement && status !== 'error' && !(statusData.type === 'track' && statusData.parent &&
|
||||||
(entry.type === 'album' || entry.type === 'playlist'))) {
|
(entry.type === 'album' || entry.type === 'playlist'))) {
|
||||||
logElement.textContent = message;
|
logElement.textContent = message;
|
||||||
@@ -2076,12 +2072,12 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
if (cancelBtn) cancelBtn.style.display = 'none';
|
if (cancelBtn) cancelBtn.style.display = 'none';
|
||||||
|
|
||||||
// Hide progress bars for errored items
|
// Hide progress bars for errored items
|
||||||
const trackProgressContainer = entry.element.querySelector(`#track-progress-container-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const trackProgressContainer = entry.element.querySelector(`#track-progress-container-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
if (trackProgressContainer) trackProgressContainer.style.display = 'none';
|
if (trackProgressContainer) trackProgressContainer.style.display = 'none';
|
||||||
const overallProgressContainer = entry.element.querySelector('.overall-progress-container') as HTMLElement | null;
|
const overallProgressContainer = entry.element.querySelector('.overall-progress-container') as HTMLElement | null;
|
||||||
if (overallProgressContainer) overallProgressContainer.style.display = 'none';
|
if (overallProgressContainer) overallProgressContainer.style.display = 'none';
|
||||||
// Hide time elapsed for errored items
|
// Hide time elapsed for errored items
|
||||||
const timeElapsedContainer = entry.element.querySelector(`#time-elapsed-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const timeElapsedContainer = entry.element.querySelector(`#time-elapsed-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
if (timeElapsedContainer) timeElapsedContainer.style.display = 'none';
|
if (timeElapsedContainer) timeElapsedContainer.style.display = 'none';
|
||||||
|
|
||||||
// Extract error details
|
// Extract error details
|
||||||
@@ -2094,7 +2090,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
|
|
||||||
console.log(`Error for ${entry.type} download. Can retry: ${!!entry.requestUrl}. Retry URL: ${entry.requestUrl}`);
|
console.log(`Error for ${entry.type} download. Can retry: ${!!entry.requestUrl}. Retry URL: ${entry.requestUrl}`);
|
||||||
|
|
||||||
const errorLogElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null; // Use a different variable name
|
const errorLogElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null; // Use a different variable name
|
||||||
if (errorLogElement) { // Check errorLogElement
|
if (errorLogElement) { // Check errorLogElement
|
||||||
let errorMessageElement = errorLogElement.querySelector('.error-message') as HTMLElement | null;
|
let errorMessageElement = errorLogElement.querySelector('.error-message') as HTMLElement | null;
|
||||||
|
|
||||||
@@ -2158,7 +2154,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache the status for potential page reloads
|
// Cache the status for potential page reloads
|
||||||
this.queueCache[entry.prgFile] = statusData;
|
this.queueCache[entry.taskId] = statusData;
|
||||||
localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache));
|
localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2212,8 +2208,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
// Update real-time progress for track downloads
|
// Update real-time progress for track downloads
|
||||||
updateRealTimeProgress(entry: QueueEntry, statusData: StatusData) { // Add types
|
updateRealTimeProgress(entry: QueueEntry, statusData: StatusData) { // Add types
|
||||||
// Get track progress bar
|
// Get track progress bar
|
||||||
const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null;
|
const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null;
|
||||||
const timeElapsedEl = entry.element.querySelector('#time-elapsed-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null;
|
const timeElapsedEl = entry.element.querySelector('#time-elapsed-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null;
|
||||||
|
|
||||||
if (trackProgressBar && statusData.progress !== undefined) {
|
if (trackProgressBar && statusData.progress !== undefined) {
|
||||||
// Update track progress bar
|
// Update track progress bar
|
||||||
@@ -2242,8 +2238,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
// Update progress for single track downloads
|
// Update progress for single track downloads
|
||||||
updateSingleTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types
|
updateSingleTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types
|
||||||
// Get track progress bar and other UI elements
|
// Get track progress bar and other UI elements
|
||||||
const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null;
|
const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null;
|
||||||
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
const titleElement = entry.element.querySelector('.title') as HTMLElement | null;
|
const titleElement = entry.element.querySelector('.title') as HTMLElement | null;
|
||||||
const artistElement = entry.element.querySelector('.artist') as HTMLElement | null;
|
const artistElement = entry.element.querySelector('.artist') as HTMLElement | null;
|
||||||
let progress = 0; // Declare progress here
|
let progress = 0; // Declare progress here
|
||||||
@@ -2348,7 +2344,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
trackProgressBar.setAttribute('aria-valuenow', safeProgress.toString()); // Use string
|
trackProgressBar.setAttribute('aria-valuenow', safeProgress.toString()); // Use string
|
||||||
|
|
||||||
// Make sure progress bar is visible
|
// Make sure progress bar is visible
|
||||||
const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null;
|
const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null;
|
||||||
if (trackProgressContainer) {
|
if (trackProgressContainer) {
|
||||||
trackProgressContainer.style.display = 'block';
|
trackProgressContainer.style.display = 'block';
|
||||||
}
|
}
|
||||||
@@ -2365,10 +2361,10 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
// Update progress for multi-track downloads (albums and playlists)
|
// Update progress for multi-track downloads (albums and playlists)
|
||||||
updateMultiTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types
|
updateMultiTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types
|
||||||
// Get progress elements
|
// Get progress elements
|
||||||
const progressCounter = document.getElementById(`progress-count-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const progressCounter = document.getElementById(`progress-count-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
const overallProgressBar = document.getElementById(`overall-bar-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const overallProgressBar = document.getElementById(`overall-bar-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null;
|
const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null;
|
||||||
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||||
const titleElement = entry.element.querySelector('.title') as HTMLElement | null;
|
const titleElement = entry.element.querySelector('.title') as HTMLElement | null;
|
||||||
const artistElement = entry.element.querySelector('.artist') as HTMLElement | null;
|
const artistElement = entry.element.querySelector('.artist') as HTMLElement | null;
|
||||||
let progress = 0; // Declare progress here for this function's scope
|
let progress = 0; // Declare progress here for this function's scope
|
||||||
@@ -2465,7 +2461,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
// Update the track-level progress bar
|
// Update the track-level progress bar
|
||||||
if (trackProgressBar) {
|
if (trackProgressBar) {
|
||||||
// Make sure progress bar container is visible
|
// Make sure progress bar container is visible
|
||||||
const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null;
|
const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.taskId) as HTMLElement | null;
|
||||||
if (trackProgressContainer) {
|
if (trackProgressContainer) {
|
||||||
trackProgressContainer.style.display = 'block';
|
trackProgressContainer.style.display = 'block';
|
||||||
}
|
}
|
||||||
@@ -2641,7 +2637,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
}
|
}
|
||||||
const serverTasks: any[] = await response.json();
|
const serverTasks: any[] = await response.json();
|
||||||
|
|
||||||
const localTaskPrgFiles = new Set(Object.values(this.queueEntries).map(entry => entry.prgFile));
|
const localTaskPrgFiles = new Set(Object.values(this.queueEntries).map(entry => entry.taskId));
|
||||||
const serverTaskPrgFiles = new Set(serverTasks.map(task => task.task_id));
|
const serverTaskPrgFiles = new Set(serverTasks.map(task => task.task_id));
|
||||||
|
|
||||||
const terminalStates = ['complete', 'done', 'cancelled', 'ERROR_AUTO_CLEANED', 'ERROR_RETRIED', 'cancel', 'interrupted', 'error'];
|
const terminalStates = ['complete', 'done', 'cancelled', 'ERROR_AUTO_CLEANED', 'ERROR_RETRIED', 'cancel', 'interrupted', 'error'];
|
||||||
@@ -2654,7 +2650,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
|
|
||||||
if (terminalStates.includes(lastStatus?.status)) {
|
if (terminalStates.includes(lastStatus?.status)) {
|
||||||
// If server says it's terminal, and we have it locally, ensure it's cleaned up
|
// If server says it's terminal, and we have it locally, ensure it's cleaned up
|
||||||
const localEntry = Object.values(this.queueEntries).find(e => e.prgFile === taskId);
|
const localEntry = Object.values(this.queueEntries).find(e => e.taskId === taskId);
|
||||||
if (localEntry && !localEntry.hasEnded) {
|
if (localEntry && !localEntry.hasEnded) {
|
||||||
console.log(`Periodic sync: Server task ${taskId} is terminal (${lastStatus.status}), cleaning up local entry.`);
|
console.log(`Periodic sync: Server task ${taskId} is terminal (${lastStatus.status}), cleaning up local entry.`);
|
||||||
// Use a status object for handleDownloadCompletion
|
// Use a status object for handleDownloadCompletion
|
||||||
@@ -2713,7 +2709,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Task exists locally, check if status needs update from server list
|
// Task exists locally, check if status needs update from server list
|
||||||
const localEntry = Object.values(this.queueEntries).find(e => e.prgFile === taskId);
|
const localEntry = Object.values(this.queueEntries).find(e => e.taskId === taskId);
|
||||||
if (localEntry && lastStatus && JSON.stringify(localEntry.lastStatus) !== JSON.stringify(lastStatus)) {
|
if (localEntry && lastStatus && JSON.stringify(localEntry.lastStatus) !== JSON.stringify(lastStatus)) {
|
||||||
if (!localEntry.hasEnded) {
|
if (!localEntry.hasEnded) {
|
||||||
console.log(`Periodic sync: Updating status for existing task ${taskId} from ${localEntry.lastStatus?.status} to ${lastStatus.status}`);
|
console.log(`Periodic sync: Updating status for existing task ${taskId} from ${localEntry.lastStatus?.status} to ${lastStatus.status}`);
|
||||||
@@ -2727,16 +2723,16 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
|||||||
|
|
||||||
// 2. Remove local tasks that are no longer on the server or are now terminal on server
|
// 2. Remove local tasks that are no longer on the server or are now terminal on server
|
||||||
for (const localEntry of Object.values(this.queueEntries)) {
|
for (const localEntry of Object.values(this.queueEntries)) {
|
||||||
if (!serverTaskPrgFiles.has(localEntry.prgFile)) {
|
if (!serverTaskPrgFiles.has(localEntry.taskId)) {
|
||||||
if (!localEntry.hasEnded) {
|
if (!localEntry.hasEnded) {
|
||||||
console.log(`Periodic sync: Local task ${localEntry.prgFile} not found on server. Assuming completed/cleaned. Removing.`);
|
console.log(`Periodic sync: Local task ${localEntry.taskId} not found on server. Assuming completed/cleaned. Removing.`);
|
||||||
this.cleanupEntry(localEntry.uniqueId);
|
this.cleanupEntry(localEntry.uniqueId);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const serverEquivalent = serverTasks.find(st => st.task_id === localEntry.prgFile);
|
const serverEquivalent = serverTasks.find(st => st.task_id === localEntry.taskId);
|
||||||
if (serverEquivalent && serverEquivalent.last_status_obj && terminalStates.includes(serverEquivalent.last_status_obj.status)) {
|
if (serverEquivalent && serverEquivalent.last_status_obj && terminalStates.includes(serverEquivalent.last_status_obj.status)) {
|
||||||
if (!localEntry.hasEnded) {
|
if (!localEntry.hasEnded) {
|
||||||
console.log(`Periodic sync: Local task ${localEntry.prgFile} is now terminal on server (${serverEquivalent.last_status_obj.status}). Cleaning up.`);
|
console.log(`Periodic sync: Local task ${localEntry.taskId} is now terminal on server (${serverEquivalent.last_status_obj.status}). Cleaning up.`);
|
||||||
this.handleDownloadCompletion(localEntry, localEntry.uniqueId, serverEquivalent.last_status_obj);
|
this.handleDownloadCompletion(localEntry, localEntry.uniqueId, serverEquivalent.last_status_obj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
tests/README.md
Normal file
44
tests/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Spotizerr Backend Tests
|
||||||
|
|
||||||
|
This directory contains automated tests for the Spotizerr backend API.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Running Backend**: Ensure the Spotizerr Flask application is running and accessible at `http://localhost:7171`. You can start it with `python app.py`.
|
||||||
|
|
||||||
|
2. **Python Dependencies**: Install the necessary Python packages for testing.
|
||||||
|
```bash
|
||||||
|
pip install pytest requests python-dotenv
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Credentials**: These tests require valid Spotify and Deezer credentials. Create a file named `.env` in the root directory of the project (`spotizerr`) and add your credentials to it. The tests will load this file automatically.
|
||||||
|
|
||||||
|
**Example `.env` file:**
|
||||||
|
```
|
||||||
|
SPOTIFY_API_CLIENT_ID="your_spotify_client_id"
|
||||||
|
SPOTIFY_API_CLIENT_SECRET="your_spotify_client_secret"
|
||||||
|
# This should be the full JSON content of your credentials blob as a single line string
|
||||||
|
SPOTIFY_BLOB_CONTENT='{"username": "your_spotify_username", "password": "your_spotify_password", ...}'
|
||||||
|
DEEZER_ARL="your_deezer_arl"
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests will automatically use these credentials to create and manage test accounts named `test-spotify-account` and `test-deezer-account`.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
To run all tests, navigate to the root directory of the project (`spotizerr`) and run `pytest`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
To run a specific test file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_downloads.py
|
||||||
|
```
|
||||||
|
|
||||||
|
For more detailed output, use the `-v` (verbose) and `-s` (show print statements) flags:
|
||||||
|
```bash
|
||||||
|
pytest -v -s
|
||||||
|
```
|
||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
149
tests/conftest.py
Normal file
149
tests/conftest.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables from .env file in the project root
|
||||||
|
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", '{}')
|
||||||
|
try:
|
||||||
|
SPOTIFY_BLOB_CONTENT = json.loads(SPOTIFY_BLOB_CONTENT_STR)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
SPOTIFY_BLOB_CONTENT = {}
|
||||||
|
|
||||||
|
DEEZER_ARL = os.environ.get("DEEZER_ARL", "your_deezer_arl")
|
||||||
|
|
||||||
|
# --- Standard names for test accounts ---
|
||||||
|
SPOTIFY_ACCOUNT_NAME = "test-spotify-account"
|
||||||
|
DEEZER_ACCOUNT_NAME = "test-deezer-account"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def base_url():
|
||||||
|
"""Provides the base URL for the API tests."""
|
||||||
|
return "http://localhost:7171/api"
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_task(base_url, task_id, timeout=600):
|
||||||
|
"""
|
||||||
|
Waits for a Celery task to reach a terminal state (complete, error, etc.).
|
||||||
|
Polls the progress endpoint and prints status updates.
|
||||||
|
"""
|
||||||
|
print(f"\n--- Waiting for task {task_id} (timeout: {timeout}s) ---")
|
||||||
|
start_time = time.time()
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{base_url}/prgs/{task_id}")
|
||||||
|
if response.status_code == 404:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
response.raise_for_status() # Raise an exception for bad status codes
|
||||||
|
|
||||||
|
statuses = response.json()
|
||||||
|
if not statuses:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_status = statuses[-1]
|
||||||
|
status = last_status.get("status")
|
||||||
|
|
||||||
|
# More verbose logging for debugging during tests
|
||||||
|
message = last_status.get('message', '')
|
||||||
|
track = last_status.get('track', '')
|
||||||
|
progress = last_status.get('overall_progress', '')
|
||||||
|
print(f"Task {task_id} | Status: {status:<12} | Progress: {progress or 'N/A':>3}% | Track: {track:<30} | Message: {message}")
|
||||||
|
|
||||||
|
if status in ["complete", "ERROR", "cancelled", "ERROR_RETRIED", "ERROR_AUTO_CLEANED"]:
|
||||||
|
print(f"--- Task {task_id} finished with status: {status} ---")
|
||||||
|
return last_status
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"Warning: Request to fetch task status for {task_id} failed: {e}. Retrying...")
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
raise TimeoutError(f"Task {task_id} did not complete within {timeout} seconds.")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def task_waiter(base_url):
|
||||||
|
"""Provides a fixture that returns the wait_for_task helper function."""
|
||||||
|
def _waiter(task_id, timeout=600):
|
||||||
|
return wait_for_task(base_url, task_id, timeout)
|
||||||
|
return _waiter
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def setup_credentials_for_tests(base_url):
|
||||||
|
"""
|
||||||
|
A session-wide, automatic fixture to set up all necessary credentials.
|
||||||
|
It runs once before any tests, and tears down the credentials after all tests are complete.
|
||||||
|
"""
|
||||||
|
print("\n--- Setting up credentials for test session ---")
|
||||||
|
|
||||||
|
print("\n--- DEBUGGING CREDENTIALS ---")
|
||||||
|
print(f"SPOTIFY_API_CLIENT_ID: {SPOTIFY_API_CLIENT_ID}")
|
||||||
|
print(f"SPOTIFY_API_CLIENT_SECRET: {SPOTIFY_API_CLIENT_SECRET}")
|
||||||
|
print(f"DEEZER_ARL: {DEEZER_ARL}")
|
||||||
|
print(f"SPOTIFY_BLOB_CONTENT {SPOTIFY_BLOB_CONTENT}")
|
||||||
|
print("--- END DEBUGGING ---\n")
|
||||||
|
|
||||||
|
# Skip all tests if secrets are not provided in the environment
|
||||||
|
if SPOTIFY_API_CLIENT_ID == "your_spotify_client_id" or \
|
||||||
|
SPOTIFY_API_CLIENT_SECRET == "your_spotify_client_secret" or \
|
||||||
|
not SPOTIFY_BLOB_CONTENT or \
|
||||||
|
DEEZER_ARL == "your_deezer_arl":
|
||||||
|
pytest.skip("Required credentials not provided in .env file or environment. Skipping credential-dependent tests.")
|
||||||
|
|
||||||
|
# 1. Set global Spotify API creds
|
||||||
|
data = {"client_id": SPOTIFY_API_CLIENT_ID, "client_secret": SPOTIFY_API_CLIENT_SECRET}
|
||||||
|
response = requests.put(f"{base_url}/credentials/spotify_api_config", json=data)
|
||||||
|
if response.status_code != 200:
|
||||||
|
pytest.fail(f"Failed to set global Spotify API creds: {response.text}")
|
||||||
|
print("Global Spotify API credentials set.")
|
||||||
|
|
||||||
|
# 2. Delete any pre-existing test credentials to ensure a clean state
|
||||||
|
requests.delete(f"{base_url}/credentials/spotify/{SPOTIFY_ACCOUNT_NAME}")
|
||||||
|
requests.delete(f"{base_url}/credentials/deezer/{DEEZER_ACCOUNT_NAME}")
|
||||||
|
print("Cleaned up any old test credentials.")
|
||||||
|
|
||||||
|
# 3. Create Deezer credential
|
||||||
|
data = {"name": DEEZER_ACCOUNT_NAME, "arl": DEEZER_ARL, "region": "US"}
|
||||||
|
response = requests.post(f"{base_url}/credentials/deezer/{DEEZER_ACCOUNT_NAME}", json=data)
|
||||||
|
if response.status_code != 201:
|
||||||
|
pytest.fail(f"Failed to create Deezer credential: {response.text}")
|
||||||
|
print("Deezer test credential created.")
|
||||||
|
|
||||||
|
# 4. Create Spotify credential
|
||||||
|
data = {"name": SPOTIFY_ACCOUNT_NAME, "blob_content": SPOTIFY_BLOB_CONTENT, "region": "US"}
|
||||||
|
response = requests.post(f"{base_url}/credentials/spotify/{SPOTIFY_ACCOUNT_NAME}", json=data)
|
||||||
|
if response.status_code != 201:
|
||||||
|
pytest.fail(f"Failed to create Spotify credential: {response.text}")
|
||||||
|
print("Spotify test credential created.")
|
||||||
|
|
||||||
|
# 5. Set main config to use these accounts for downloads
|
||||||
|
config_payload = {
|
||||||
|
"spotify": SPOTIFY_ACCOUNT_NAME,
|
||||||
|
"deezer": DEEZER_ACCOUNT_NAME,
|
||||||
|
}
|
||||||
|
response = requests.post(f"{base_url}/config", json=config_payload)
|
||||||
|
if response.status_code != 200:
|
||||||
|
pytest.fail(f"Failed to set main config for tests: {response.text}")
|
||||||
|
print("Main config set to use test credentials.")
|
||||||
|
|
||||||
|
yield # This is where the tests will run
|
||||||
|
|
||||||
|
# --- Teardown ---
|
||||||
|
print("\n--- Tearing down test credentials ---")
|
||||||
|
response = requests.delete(f"{base_url}/credentials/spotify/{SPOTIFY_ACCOUNT_NAME}")
|
||||||
|
assert response.status_code in [200, 404]
|
||||||
|
response = requests.delete(f"{base_url}/credentials/deezer/{DEEZER_ACCOUNT_NAME}")
|
||||||
|
assert response.status_code in [200, 404]
|
||||||
|
print("Test credentials deleted.")
|
||||||
94
tests/test_config.py
Normal file
94
tests/test_config.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import requests
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def reset_config(base_url):
|
||||||
|
"""A fixture to ensure the main config is reset after a test case."""
|
||||||
|
response = requests.get(f"{base_url}/config")
|
||||||
|
assert response.status_code == 200
|
||||||
|
original_config = response.json()
|
||||||
|
yield
|
||||||
|
response = requests.post(f"{base_url}/config", json=original_config)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_get_main_config(base_url):
|
||||||
|
"""Tests if the main configuration can be retrieved."""
|
||||||
|
response = requests.get(f"{base_url}/config")
|
||||||
|
assert response.status_code == 200
|
||||||
|
config = response.json()
|
||||||
|
assert "service" in config
|
||||||
|
assert "maxConcurrentDownloads" in config
|
||||||
|
assert "spotify" in config # Should be set by conftest
|
||||||
|
assert "deezer" in config # Should be set by conftest
|
||||||
|
|
||||||
|
def test_update_main_config(base_url, reset_config):
|
||||||
|
"""Tests updating various fields in the main configuration."""
|
||||||
|
new_settings = {
|
||||||
|
"maxConcurrentDownloads": 5,
|
||||||
|
"spotifyQuality": "HIGH",
|
||||||
|
"deezerQuality": "FLAC",
|
||||||
|
"customDirFormat": "%artist%/%album%",
|
||||||
|
"customTrackFormat": "%tracknum% %title%",
|
||||||
|
"save_cover": False,
|
||||||
|
"fallback": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(f"{base_url}/config", json=new_settings)
|
||||||
|
assert response.status_code == 200
|
||||||
|
updated_config = response.json()
|
||||||
|
|
||||||
|
for key, value in new_settings.items():
|
||||||
|
assert updated_config[key] == value
|
||||||
|
|
||||||
|
def test_get_watch_config(base_url):
|
||||||
|
"""Tests if the watch-specific configuration can be retrieved."""
|
||||||
|
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
|
||||||
|
|
||||||
|
def test_update_watch_config(base_url):
|
||||||
|
"""Tests updating the watch-specific configuration."""
|
||||||
|
response = requests.get(f"{base_url}/config/watch")
|
||||||
|
original_config = response.json()
|
||||||
|
|
||||||
|
new_settings = {
|
||||||
|
"delay_between_playlists_seconds": 120,
|
||||||
|
"delay_between_artists_seconds": 240,
|
||||||
|
"auto_add_new_releases_to_queue": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(f"{base_url}/config/watch", json=new_settings)
|
||||||
|
assert response.status_code == 200
|
||||||
|
updated_config = response.json()
|
||||||
|
|
||||||
|
for key, value in new_settings.items():
|
||||||
|
assert updated_config[key] == value
|
||||||
|
|
||||||
|
# Revert to original
|
||||||
|
requests.post(f"{base_url}/config/watch", json=original_config)
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
conversion_formats = ["mp3", "flac", "ogg", "opus", "m4a"]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
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["bitrate"] == br
|
||||||
128
tests/test_downloads.py
Normal file
128
tests/test_downloads.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import requests
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# URLs provided by the user 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"
|
||||||
|
SPOTIFY_ARTIST_URL = "https://open.spotify.com/artist/7l6cdPhOLYO7lehz5xfzLV"
|
||||||
|
|
||||||
|
# Corresponding IDs extracted from URLs
|
||||||
|
TRACK_ID = SPOTIFY_TRACK_URL.split('/')[-1].split('?')[0]
|
||||||
|
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]
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def reset_config(base_url):
|
||||||
|
"""Fixture to reset the main config after a test to avoid side effects."""
|
||||||
|
response = requests.get(f"{base_url}/config")
|
||||||
|
original_config = response.json()
|
||||||
|
yield
|
||||||
|
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 ---")
|
||||||
|
config_payload = {
|
||||||
|
"service": "spotify",
|
||||||
|
"fallback": False,
|
||||||
|
"realTime": True,
|
||||||
|
"spotifyQuality": "NORMAL" # Simulating free account quality
|
||||||
|
}
|
||||||
|
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: {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)
|
||||||
|
|
||||||
|
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 ---")
|
||||||
|
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", [])
|
||||||
|
assert len(queued_albums) > 0, "No albums were queued for the artist."
|
||||||
|
|
||||||
|
for album in queued_albums:
|
||||||
|
task_id = album["task_id"]
|
||||||
|
print(f"--- Waiting for artist album: {album['name']} ({task_id}) ---")
|
||||||
|
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 ---")
|
||||||
|
config_payload = {
|
||||||
|
"service": "spotify",
|
||||||
|
"fallback": True,
|
||||||
|
"deezerQuality": "MP3_320" # Simulating higher quality from Deezer free
|
||||||
|
}
|
||||||
|
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: {final_status.get('error')}"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("format,bitrate", [
|
||||||
|
("mp3", "320"), ("mp3", "128"),
|
||||||
|
("flac", None),
|
||||||
|
("ogg", "160"),
|
||||||
|
("opus", "128"),
|
||||||
|
("m4a", "128k")
|
||||||
|
])
|
||||||
|
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'} ---")
|
||||||
|
config_payload = {
|
||||||
|
"service": "spotify",
|
||||||
|
"fallback": False,
|
||||||
|
"realTime": True,
|
||||||
|
"spotifyQuality": "NORMAL",
|
||||||
|
"convertTo": format,
|
||||||
|
"bitrate": bitrate
|
||||||
|
}
|
||||||
|
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"Download failed for format {format} bitrate {bitrate}: {final_status.get('error')}"
|
||||||
61
tests/test_history.py
Normal file
61
tests/test_history.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import requests
|
||||||
|
import pytest
|
||||||
|
import time
|
||||||
|
|
||||||
|
TRACK_ID = "1Cts4YV9aOXVAP3bm3Ro6r" # Use a known, short track
|
||||||
|
|
||||||
|
@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_history_logging_and_filtering(base_url, task_waiter, reset_config):
|
||||||
|
"""
|
||||||
|
Tests if a completed download appears in the history and
|
||||||
|
verifies that history filtering works correctly.
|
||||||
|
"""
|
||||||
|
# First, complete a download task to ensure there's a history entry
|
||||||
|
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"]
|
||||||
|
task_waiter(task_id) # Wait for the download to complete
|
||||||
|
|
||||||
|
# Give a moment for history to be written if it's asynchronous
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# 1. Get all history and check if our task is present
|
||||||
|
print("\n--- Verifying task appears in general history ---")
|
||||||
|
response = requests.get(f"{base_url}/history")
|
||||||
|
assert response.status_code == 200
|
||||||
|
history_data = response.json()
|
||||||
|
assert "entries" in history_data
|
||||||
|
assert "total" in history_data
|
||||||
|
assert history_data["total"] > 0
|
||||||
|
|
||||||
|
# Find our specific task in the history
|
||||||
|
history_entry = next((entry for entry in history_data["entries"] if entry['task_id'] == task_id), None)
|
||||||
|
assert history_entry is not None, f"Task {task_id} not found in download history."
|
||||||
|
assert history_entry["status_final"] == "COMPLETED"
|
||||||
|
|
||||||
|
# 2. Test filtering for COMPLETED tasks
|
||||||
|
print("\n--- Verifying history filtering for COMPLETED status ---")
|
||||||
|
response = requests.get(f"{base_url}/history?filters[status_final]=COMPLETED")
|
||||||
|
assert response.status_code == 200
|
||||||
|
completed_history = response.json()
|
||||||
|
assert completed_history["total"] > 0
|
||||||
|
assert any(entry['task_id'] == task_id for entry in completed_history["entries"])
|
||||||
|
assert all(entry['status_final'] == 'COMPLETED' for entry in completed_history["entries"])
|
||||||
|
|
||||||
|
# 3. Test filtering for an item name
|
||||||
|
print(f"\n--- Verifying history filtering for item_name: {history_entry['item_name']} ---")
|
||||||
|
item_name_query = requests.utils.quote(history_entry['item_name'])
|
||||||
|
response = requests.get(f"{base_url}/history?filters[item_name]={item_name_query}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
named_history = response.json()
|
||||||
|
assert named_history["total"] > 0
|
||||||
|
assert any(entry['task_id'] == task_id for entry in named_history["entries"])
|
||||||
93
tests/test_prgs.py
Normal file
93
tests/test_prgs.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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"
|
||||||
35
tests/test_search.py
Normal file
35
tests/test_search.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import requests
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
def test_search_spotify_artist(base_url):
|
||||||
|
"""Tests searching for an artist on Spotify."""
|
||||||
|
response = requests.get(f"{base_url}/search?q=Daft+Punk&search_type=artist")
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()
|
||||||
|
assert "items" in results
|
||||||
|
assert len(results["items"]) > 0
|
||||||
|
assert "Daft Punk" in results["items"][0]["name"]
|
||||||
|
|
||||||
|
def test_search_spotify_track(base_url):
|
||||||
|
"""Tests searching for a track on Spotify."""
|
||||||
|
response = requests.get(f"{base_url}/search?q=Get+Lucky&search_type=track")
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()
|
||||||
|
assert "items" in results
|
||||||
|
assert len(results["items"]) > 0
|
||||||
|
|
||||||
|
def test_search_deezer_track(base_url):
|
||||||
|
"""Tests searching for a track on Deezer."""
|
||||||
|
response = requests.get(f"{base_url}/search?q=Instant+Crush&search_type=track")
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()
|
||||||
|
assert "items" in results
|
||||||
|
assert len(results["items"]) > 0
|
||||||
|
|
||||||
|
def test_search_deezer_album(base_url):
|
||||||
|
"""Tests searching for an album on Deezer."""
|
||||||
|
response = requests.get(f"{base_url}/search?q=Random+Access+Memories&search_type=album")
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()
|
||||||
|
assert "items" in results
|
||||||
|
assert len(results["items"]) > 0
|
||||||
117
tests/test_watch.py
Normal file
117
tests/test_watch.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import requests
|
||||||
|
import pytest
|
||||||
|
import time
|
||||||
|
|
||||||
|
SPOTIFY_PLAYLIST_ID = "26CiMxIxdn5WhXyccMCPOB"
|
||||||
|
SPOTIFY_ARTIST_ID = "7l6cdPhOLYO7lehz5xfzLV"
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_and_cleanup_watch_tests(base_url):
|
||||||
|
"""
|
||||||
|
A fixture that enables watch mode, cleans the watchlist before each test,
|
||||||
|
and then restores original state and cleans up after each test.
|
||||||
|
"""
|
||||||
|
# Get original watch config to restore it later
|
||||||
|
response = requests.get(f"{base_url}/config/watch")
|
||||||
|
assert response.status_code == 200
|
||||||
|
original_config = response.json()
|
||||||
|
|
||||||
|
# Enable watch mode for testing if it's not already
|
||||||
|
if not original_config.get("enabled"):
|
||||||
|
response = requests.post(f"{base_url}/config/watch", json={"enabled": True})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Cleanup any existing watched items before the test
|
||||||
|
requests.delete(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}")
|
||||||
|
requests.delete(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Cleanup watched items created during the test
|
||||||
|
requests.delete(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}")
|
||||||
|
requests.delete(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}")
|
||||||
|
|
||||||
|
# Restore original watch config
|
||||||
|
response = requests.post(f"{base_url}/config/watch", json=original_config)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_add_and_list_playlist_to_watch(base_url):
|
||||||
|
"""Tests adding a playlist to the watch list and verifying it appears in the list."""
|
||||||
|
response = requests.put(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Playlist added to watch list" in response.json()["message"]
|
||||||
|
|
||||||
|
# Verify it's in the watched list
|
||||||
|
response = requests.get(f"{base_url}/playlist/watch/list")
|
||||||
|
assert response.status_code == 200
|
||||||
|
watched_playlists = response.json()
|
||||||
|
assert any(p['spotify_id'] == SPOTIFY_PLAYLIST_ID for p in watched_playlists)
|
||||||
|
|
||||||
|
def test_add_and_list_artist_to_watch(base_url):
|
||||||
|
"""Tests adding an artist to the watch list and verifying it appears in the list."""
|
||||||
|
response = requests.put(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Artist added to watch list" in response.json()["message"]
|
||||||
|
|
||||||
|
# Verify it's in the watched list
|
||||||
|
response = requests.get(f"{base_url}/artist/watch/list")
|
||||||
|
assert response.status_code == 200
|
||||||
|
watched_artists = response.json()
|
||||||
|
assert any(a['spotify_id'] == SPOTIFY_ARTIST_ID for a in watched_artists)
|
||||||
|
|
||||||
|
def test_trigger_playlist_check(base_url):
|
||||||
|
"""Tests the endpoint for manually triggering a check on a watched playlist."""
|
||||||
|
# First, add the playlist to the watch list
|
||||||
|
requests.put(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}")
|
||||||
|
|
||||||
|
# Trigger the check
|
||||||
|
response = requests.post(f"{base_url}/playlist/watch/trigger_check/{SPOTIFY_PLAYLIST_ID}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Check triggered for playlist" in response.json()["message"]
|
||||||
|
|
||||||
|
# A full verification would require inspecting the database or new tasks,
|
||||||
|
# but for an API test, confirming the trigger endpoint responds correctly is the key goal.
|
||||||
|
print("Playlist check triggered. Note: This does not verify new downloads were queued.")
|
||||||
|
|
||||||
|
def test_trigger_artist_check(base_url):
|
||||||
|
"""Tests the endpoint for manually triggering a check on a watched artist."""
|
||||||
|
# First, add the artist to the watch list
|
||||||
|
requests.put(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}")
|
||||||
|
|
||||||
|
# Trigger the check
|
||||||
|
response = requests.post(f"{base_url}/artist/watch/trigger_check/{SPOTIFY_ARTIST_ID}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Check triggered for artist" in response.json()["message"]
|
||||||
|
print("Artist check triggered. Note: This does not verify new downloads were queued.")
|
||||||
|
|
||||||
|
def test_remove_playlist_from_watch(base_url):
|
||||||
|
"""Tests removing a playlist from the watch list."""
|
||||||
|
# Add the playlist first to ensure it exists
|
||||||
|
requests.put(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}")
|
||||||
|
|
||||||
|
# Now, remove it
|
||||||
|
response = requests.delete(f"{base_url}/playlist/watch/{SPOTIFY_PLAYLIST_ID}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Playlist removed from watch list" in response.json()["message"]
|
||||||
|
|
||||||
|
# Verify it's no longer in the list
|
||||||
|
response = requests.get(f"{base_url}/playlist/watch/list")
|
||||||
|
assert response.status_code == 200
|
||||||
|
watched_playlists = response.json()
|
||||||
|
assert not any(p['spotify_id'] == SPOTIFY_PLAYLIST_ID for p in watched_playlists)
|
||||||
|
|
||||||
|
def test_remove_artist_from_watch(base_url):
|
||||||
|
"""Tests removing an artist from the watch list."""
|
||||||
|
# Add the artist first to ensure it exists
|
||||||
|
requests.put(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}")
|
||||||
|
|
||||||
|
# Now, remove it
|
||||||
|
response = requests.delete(f"{base_url}/artist/watch/{SPOTIFY_ARTIST_ID}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Artist removed from watch list" in response.json()["message"]
|
||||||
|
|
||||||
|
# Verify it's no longer in the list
|
||||||
|
response = requests.get(f"{base_url}/artist/watch/list")
|
||||||
|
assert response.status_code == 200
|
||||||
|
watched_artists = response.json()
|
||||||
|
assert not any(a['spotify_id'] == SPOTIFY_ARTIST_ID for a in watched_artists)
|
||||||
Reference in New Issue
Block a user