test suite
This commit is contained in:
@@ -24,3 +24,4 @@ logs/
|
||||
.env
|
||||
.venv
|
||||
data
|
||||
tests/
|
||||
@@ -111,25 +111,25 @@ def handle_download(album_id):
|
||||
)
|
||||
|
||||
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"])
|
||||
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")
|
||||
if not prg_file:
|
||||
task_id = request.args.get("task_id")
|
||||
if not task_id:
|
||||
return Response(
|
||||
json.dumps({"error": "Missing process id (prg_file) parameter"}),
|
||||
json.dumps({"error": "Missing process id (task_id) parameter"}),
|
||||
status=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
return Response(json.dumps(result), status=status_code, mimetype="application/json")
|
||||
|
||||
@@ -133,7 +133,7 @@ def handle_download(playlist_id):
|
||||
)
|
||||
|
||||
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,
|
||||
mimetype="application/json",
|
||||
)
|
||||
@@ -142,18 +142,18 @@ def handle_download(playlist_id):
|
||||
@playlist_bp.route("/download/cancel", methods=["GET"])
|
||||
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")
|
||||
if not prg_file:
|
||||
task_id = request.args.get("task_id")
|
||||
if not task_id:
|
||||
return Response(
|
||||
json.dumps({"error": "Missing process id (prg_file) parameter"}),
|
||||
json.dumps({"error": "Missing task id (task_id) parameter"}),
|
||||
status=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
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"])
|
||||
def get_prg_file(task_id):
|
||||
def get_task_details(task_id):
|
||||
"""
|
||||
Return a JSON object with the resource type, its name (title),
|
||||
the last progress update, and, if available, the original request parameters.
|
||||
|
||||
This function works with both the old PRG file system (for backward compatibility)
|
||||
and the new task ID based system.
|
||||
This function works with the new task ID based system.
|
||||
|
||||
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
|
||||
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"])
|
||||
def delete_prg_file(task_id):
|
||||
def delete_task(task_id):
|
||||
"""
|
||||
Delete a task's information and history.
|
||||
Works with both the old PRG file system and the new task ID based system.
|
||||
|
||||
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
|
||||
task_info = get_task_info(task_id)
|
||||
@@ -107,7 +105,7 @@ def delete_prg_file(task_id):
|
||||
|
||||
|
||||
@prgs_bp.route("/list", methods=["GET"])
|
||||
def list_prg_files():
|
||||
def list_tasks():
|
||||
"""
|
||||
Retrieve a list of all tasks in the system.
|
||||
Returns a detailed list of task objects including status and metadata.
|
||||
|
||||
@@ -127,7 +127,7 @@ def handle_download(track_id):
|
||||
)
|
||||
|
||||
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,
|
||||
mimetype="application/json",
|
||||
)
|
||||
@@ -136,18 +136,18 @@ def handle_download(track_id):
|
||||
@track_bp.route("/download/cancel", methods=["GET"])
|
||||
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")
|
||||
if not prg_file:
|
||||
task_id = request.args.get("task_id")
|
||||
if not task_id:
|
||||
return Response(
|
||||
json.dumps({"error": "Missing process id (prg_file) parameter"}),
|
||||
json.dumps({"error": "Missing task id (task_id) parameter"}),
|
||||
status=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
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;
|
||||
max_retries?: number; // from config potentially
|
||||
seconds_left?: number;
|
||||
prg_file?: string;
|
||||
task_id?: string;
|
||||
url?: string;
|
||||
reason?: string; // for skipped
|
||||
parent?: ParentInfo;
|
||||
@@ -100,7 +100,7 @@ interface StatusData {
|
||||
interface QueueEntry {
|
||||
item: QueueItem;
|
||||
type: string;
|
||||
prgFile: string;
|
||||
taskId: string;
|
||||
requestUrl: string | null;
|
||||
element: HTMLElement;
|
||||
lastStatus: StatusData;
|
||||
@@ -196,13 +196,13 @@ export class DownloadQueue {
|
||||
// const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount");
|
||||
// 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") || "{}");
|
||||
|
||||
// Wait for initDOM to complete before setting up event listeners and loading existing PRG files.
|
||||
this.initDOM().then(() => {
|
||||
this.initEventListeners();
|
||||
this.loadExistingPrgFiles();
|
||||
this.loadExistingTasks();
|
||||
// Start periodic sync
|
||||
setInterval(() => this.periodicSyncWithServer(), 10000); // Sync every 10 seconds
|
||||
});
|
||||
@@ -278,8 +278,8 @@ export class DownloadQueue {
|
||||
cancelAllBtn.addEventListener('click', () => {
|
||||
for (const queueId in this.queueEntries) {
|
||||
const entry = this.queueEntries[queueId];
|
||||
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
||||
if (entry && !entry.hasEnded && entry.prgFile) {
|
||||
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.taskId}`) as HTMLElement | null;
|
||||
if (entry && !entry.hasEnded && entry.taskId) {
|
||||
// Mark as cancelling visually
|
||||
if (entry.element) {
|
||||
entry.element.classList.add('cancelling');
|
||||
@@ -289,7 +289,7 @@ export class DownloadQueue {
|
||||
}
|
||||
|
||||
// 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(data => {
|
||||
// API returns status 'cancelled' when cancellation succeeds
|
||||
@@ -388,9 +388,9 @@ export class DownloadQueue {
|
||||
/**
|
||||
* 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 entry = this.createQueueEntry(item, type, prgFile, queueId, requestUrl);
|
||||
const entry = this.createQueueEntry(item, type, taskId, queueId, requestUrl);
|
||||
this.queueEntries[queueId] = entry;
|
||||
// Re-render and update which entries are processed.
|
||||
this.updateQueueOrder();
|
||||
@@ -417,17 +417,17 @@ export class DownloadQueue {
|
||||
|
||||
// Show a preparing message for new entries
|
||||
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) {
|
||||
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
|
||||
try {
|
||||
const response = await fetch(`/api/prgs/${entry.prgFile}`);
|
||||
const response = await fetch(`/api/prgs/${entry.taskId}`);
|
||||
if (response.ok) {
|
||||
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
|
||||
|
||||
// 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) {
|
||||
const statusMessage = this.getStatusMessage(data.last_line);
|
||||
logElement.textContent = statusMessage;
|
||||
@@ -474,7 +474,7 @@ export class DownloadQueue {
|
||||
this.applyStatusClasses(entry, data.last_line);
|
||||
|
||||
// Save updated status to cache, ensuring we preserve parent data
|
||||
this.queueCache[entry.prgFile] = {
|
||||
this.queueCache[entry.taskId] = {
|
||||
...data.last_line,
|
||||
// Ensure parent data is preserved
|
||||
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.
|
||||
*/
|
||||
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}`);
|
||||
|
||||
// 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 (cachedData) {
|
||||
@@ -588,9 +588,9 @@ export class DownloadQueue {
|
||||
const entry: QueueEntry = { // Add type to entry
|
||||
item,
|
||||
type,
|
||||
prgFile,
|
||||
taskId,
|
||||
requestUrl, // for potential retry
|
||||
element: this.createQueueItem(item, type, prgFile, queueId),
|
||||
element: this.createQueueItem(item, type, taskId, queueId),
|
||||
lastStatus: {
|
||||
// Initialize with basic item metadata for immediate display
|
||||
type,
|
||||
@@ -615,7 +615,7 @@ export class DownloadQueue {
|
||||
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) {
|
||||
entry.lastStatus = cachedData;
|
||||
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.
|
||||
*/
|
||||
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)
|
||||
const isMultiTrack = type === 'album' || type === 'playlist';
|
||||
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>` : ''}
|
||||
<div class="type ${type}">${displayType}</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;">
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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) -->
|
||||
<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">
|
||||
<!-- 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" id="track-progress-bar-${queueId}-${prgFile}"
|
||||
<div class="track-progress-bar-container" id="track-progress-container-${queueId}-${taskId}">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 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>`;
|
||||
|
||||
@@ -693,10 +693,10 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
||||
<div class="overall-progress-container">
|
||||
<div class="overall-progress-header">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -745,7 +745,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
||||
case 'error':
|
||||
entry.element.classList.add('error');
|
||||
// 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) {
|
||||
errorDetailsContainer.style.display = 'none';
|
||||
}
|
||||
@@ -755,7 +755,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
||||
entry.element.classList.add('complete');
|
||||
// Hide error details if present
|
||||
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) {
|
||||
errorDetailsContainer.style.display = 'none';
|
||||
}
|
||||
@@ -765,7 +765,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
||||
entry.element.classList.add('cancelled');
|
||||
// Hide error details if present
|
||||
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) {
|
||||
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
|
||||
if (!btn) return; // Guard clause
|
||||
btn.style.display = 'none';
|
||||
const { prg, type, queueid } = btn.dataset;
|
||||
if (!prg || !type || !queueid) return; // Guard against undefined dataset properties
|
||||
const { taskid, type, queueid } = btn.dataset;
|
||||
if (!taskid || !type || !queueid) return; // Guard against undefined dataset properties
|
||||
|
||||
try {
|
||||
// Get the queue item element
|
||||
@@ -790,13 +790,13 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
logElement.textContent = "Cancelling...";
|
||||
}
|
||||
|
||||
// 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();
|
||||
// API returns status 'cancelled' when cancellation succeeds
|
||||
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
|
||||
entry.status = "cancelled";
|
||||
this.queueCache[prg] = { status: "cancelled" };
|
||||
this.queueCache[taskid] = { status: "cancelled" };
|
||||
localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache));
|
||||
|
||||
// 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
|
||||
Object.values(this.queueEntries).forEach(entry => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -995,8 +995,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
||||
delete this.queueEntries[queueId];
|
||||
|
||||
// Remove the cached info
|
||||
if (this.queueCache[entry.prgFile]) {
|
||||
delete this.queueCache[entry.prgFile];
|
||||
if (this.queueCache[entry.taskId]) {
|
||||
delete this.queueCache[entry.taskId];
|
||||
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
|
||||
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
|
||||
);
|
||||
|
||||
if (prgFile) {
|
||||
if (taskId) {
|
||||
const queueId = Object.keys(this.queueEntries).find(id =>
|
||||
this.queueEntries[id].prgFile === prgFile
|
||||
this.queueEntries[id].taskId === taskId
|
||||
);
|
||||
if (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
|
||||
|
||||
if (retryData.prg_file) {
|
||||
const newPrgFile = retryData.prg_file;
|
||||
if (retryData.task_id) {
|
||||
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.
|
||||
await this.cleanupEntry(queueId);
|
||||
|
||||
// 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 {
|
||||
if (errorMessageDiv) errorMessageDiv.textContent = 'Retry failed: invalid response from server.';
|
||||
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
|
||||
this.toggleVisibility(true);
|
||||
|
||||
// Just load existing PRG files as a fallback
|
||||
await this.loadExistingPrgFiles();
|
||||
// Just load existing task files as a fallback
|
||||
await this.loadExistingTasks();
|
||||
|
||||
// Force start monitoring for all loaded entries
|
||||
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)
|
||||
if ('prg_file' in data && data.prg_file) { // Type guard
|
||||
console.log(`Adding ${type} PRG file: ${data.prg_file}`);
|
||||
if ('task_id' in data && data.task_id) { // Type guard
|
||||
console.log(`Adding ${type} task with ID: ${data.task_id}`);
|
||||
|
||||
// Store the initial metadata in the cache so it's available
|
||||
// even before the first status update
|
||||
this.queueCache[data.prg_file] = {
|
||||
this.queueCache[data.task_id] = {
|
||||
type,
|
||||
status: 'initializing',
|
||||
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
|
||||
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
|
||||
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 {
|
||||
// Clear existing queue entries first to avoid duplicates when refreshing
|
||||
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'];
|
||||
|
||||
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 originalRequest = taskData.original_request || {};
|
||||
|
||||
// Skip adding to UI if the task is already in a terminal state
|
||||
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
|
||||
if (this.queueCache[prgFile]) {
|
||||
delete this.queueCache[prgFile];
|
||||
if (this.queueCache[taskId]) {
|
||||
delete this.queueCache[taskId];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let itemType = taskData.type || originalRequest.type || 'unknown';
|
||||
let dummyItem: QueueItem = {
|
||||
name: taskData.name || originalRequest.name || prgFile,
|
||||
name: taskData.name || originalRequest.name || taskId,
|
||||
artist: taskData.artist || originalRequest.artist || '',
|
||||
type: itemType,
|
||||
url: originalRequest.url || lastStatus?.url || '',
|
||||
@@ -1680,29 +1680,25 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
||||
dummyItem = {
|
||||
name: parent.title || 'Unknown Album',
|
||||
artist: parent.artist || 'Unknown Artist',
|
||||
type: 'album',
|
||||
url: parent.url || '',
|
||||
type: 'album', url: parent.url || '',
|
||||
total_tracks: parent.total_tracks || lastStatus.total_tracks,
|
||||
parent: parent
|
||||
};
|
||||
parent: parent };
|
||||
} else if (parent.type === 'playlist') {
|
||||
itemType = 'playlist';
|
||||
dummyItem = {
|
||||
name: parent.name || 'Unknown Playlist',
|
||||
owner: parent.owner || 'Unknown Creator',
|
||||
type: 'playlist',
|
||||
url: parent.url || '',
|
||||
type: 'playlist', url: parent.url || '',
|
||||
total_tracks: parent.total_tracks || lastStatus.total_tracks,
|
||||
parent: parent
|
||||
};
|
||||
parent: parent };
|
||||
}
|
||||
}
|
||||
|
||||
let retryCount = 0;
|
||||
if (lastStatus && lastStatus.retry_count) {
|
||||
retryCount = lastStatus.retry_count;
|
||||
} else if (prgFile.includes('_retry')) {
|
||||
const retryMatch = prgFile.match(/_retry(\d+)/);
|
||||
} else if (taskId.includes('_retry')) {
|
||||
const retryMatch = taskId.match(/_retry(\d+)/);
|
||||
if (retryMatch && retryMatch[1]) {
|
||||
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 queueId = this.generateQueueId();
|
||||
const entry = this.createQueueEntry(dummyItem, itemType, prgFile, queueId, requestUrl);
|
||||
const entry = this.createQueueEntry(dummyItem, itemType, taskId, queueId, requestUrl);
|
||||
entry.retryCount = retryCount;
|
||||
|
||||
if (lastStatus) {
|
||||
@@ -1719,7 +1715,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
||||
if (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);
|
||||
|
||||
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.startMonitoringActiveEntries();
|
||||
} 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
|
||||
console.log(`Setting up polling for ${queueId}`);
|
||||
const entry = this.queueEntries[queueId];
|
||||
if (!entry || !entry.prgFile) {
|
||||
console.warn(`No entry or prgFile for ${queueId}`);
|
||||
if (!entry || !entry.taskId) {
|
||||
console.warn(`No entry or taskId for ${queueId}`);
|
||||
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
|
||||
} catch (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) {
|
||||
logElement.textContent = `Error with download: ${(error as Error).message}`; // Cast to 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
|
||||
const entry = this.queueEntries[queueId];
|
||||
if (!entry || !entry.prgFile) {
|
||||
console.warn(`No entry or prgFile for ${queueId}`);
|
||||
if (!entry || !entry.taskId) {
|
||||
console.warn(`No entry or taskId for ${queueId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/prgs/${entry.prgFile}`);
|
||||
const response = await fetch(`/api/prgs/${entry.taskId}`);
|
||||
if (!response.ok) {
|
||||
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);
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
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.error = 'Download stalled (no progress updates for 5 minutes)';
|
||||
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
|
||||
// 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 &&
|
||||
(entry.type === 'album' || entry.type === 'playlist'))) {
|
||||
logElement.textContent = message;
|
||||
@@ -2076,12 +2072,12 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
||||
if (cancelBtn) cancelBtn.style.display = 'none';
|
||||
|
||||
// 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';
|
||||
const overallProgressContainer = entry.element.querySelector('.overall-progress-container') as HTMLElement | null;
|
||||
if (overallProgressContainer) overallProgressContainer.style.display = 'none';
|
||||
// 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';
|
||||
|
||||
// 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}`);
|
||||
|
||||
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
|
||||
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
|
||||
this.queueCache[entry.prgFile] = statusData;
|
||||
this.queueCache[entry.taskId] = statusData;
|
||||
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
|
||||
updateRealTimeProgress(entry: QueueEntry, statusData: StatusData) { // Add types
|
||||
// Get track progress bar
|
||||
const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null;
|
||||
const timeElapsedEl = entry.element.querySelector('#time-elapsed-' + 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.taskId) as HTMLElement | null;
|
||||
|
||||
if (trackProgressBar && statusData.progress !== undefined) {
|
||||
// Update track progress bar
|
||||
@@ -2242,8 +2238,8 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
||||
// Update progress for single track downloads
|
||||
updateSingleTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types
|
||||
// Get track progress bar and other UI elements
|
||||
const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null;
|
||||
const logElement = document.getElementById(`log-${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.taskId}`) as HTMLElement | null;
|
||||
const titleElement = entry.element.querySelector('.title') as HTMLElement | null;
|
||||
const artistElement = entry.element.querySelector('.artist') as HTMLElement | null;
|
||||
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
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
updateMultiTrackProgress(entry: QueueEntry, statusData: StatusData) { // Add types
|
||||
// Get progress elements
|
||||
const progressCounter = document.getElementById(`progress-count-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
||||
const overallProgressBar = document.getElementById(`overall-bar-${entry.uniqueId}-${entry.prgFile}`) as HTMLElement | null;
|
||||
const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile) as HTMLElement | null;
|
||||
const logElement = document.getElementById(`log-${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.taskId}`) 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.taskId}`) as HTMLElement | null;
|
||||
const titleElement = entry.element.querySelector('.title') as HTMLElement | null;
|
||||
const artistElement = entry.element.querySelector('.artist') as HTMLElement | null;
|
||||
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
|
||||
if (trackProgressBar) {
|
||||
// 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) {
|
||||
trackProgressContainer.style.display = 'block';
|
||||
}
|
||||
@@ -2641,7 +2637,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
||||
}
|
||||
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 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 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) {
|
||||
console.log(`Periodic sync: Server task ${taskId} is terminal (${lastStatus.status}), cleaning up local entry.`);
|
||||
// Use a status object for handleDownloadCompletion
|
||||
@@ -2713,7 +2709,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
|
||||
}
|
||||
} else {
|
||||
// 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.hasEnded) {
|
||||
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
|
||||
for (const localEntry of Object.values(this.queueEntries)) {
|
||||
if (!serverTaskPrgFiles.has(localEntry.prgFile)) {
|
||||
if (!serverTaskPrgFiles.has(localEntry.taskId)) {
|
||||
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);
|
||||
}
|
||||
} 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 (!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);
|
||||
}
|
||||
}
|
||||
|
||||
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