test suite

This commit is contained in:
Xoconoch
2025-06-07 14:56:13 -06:00
parent e97efb6b19
commit e81ee40a1d
15 changed files with 846 additions and 129 deletions

View File

@@ -24,3 +24,4 @@ logs/
.env
.venv
data
tests/

View File

@@ -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")

View File

@@ -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")

View File

@@ -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.

View File

@@ -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")

View File

@@ -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
View 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
View File

@@ -0,0 +1 @@

149
tests/conftest.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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)