merge 1.0.7 (#525)

* feat: simplify docker path and make toml path dynamic (#516)

* feat(api) no download mode (#523)

* feat(api): add no_download flag

* feat(api) no download mode

* Fix tests

* feat(api): separate history from queue file list (#514)

* feat(api): separate history from queue file list

* feat(front): add front history

* feat: add docs and test

* Cache files reading

* Use lookups for performance

* fix(api): return correct array from object

* Fix loading state

---------

Co-authored-by: unthoiz <unthoiz@protonmail.com>

---------

Co-authored-by: unthoiz <unthoiz@protonmail.com>
This commit is contained in:
cl.standunthoiz authored and GitHub committed 2025-12-03 17:35:30 +01:00
1 parent 56405d2bce
commit 7ac3d4bfc7
45 files changed
+1387 -3080

No files matched your search

+2
View File
@@ -22,3 +22,5 @@ APPRISE_API_TAG=
M3U_BASEPATH_FILE=
SYNC_CRON_EXPRESSION=
PUSH_OVER_URL=
NO_DOWNLOAD=
ENABLE_HISTORY=
+2 -6
View File
@@ -20,21 +20,17 @@ jobs:
with:
node-version: 20
cache: 'yarn'
cache-dependency-path: e2e/yarn.lock
- name: Install dependencies
working-directory: ./e2e
run: yarn install --frozen-lockfile
- name: Install Playwright Browsers
working-directory: ./e2e
run: npx playwright install --with-deps chromium
run: yarn workspace tidarr-testing exec playwright install --with-deps chromium
- name: Run Playwright tests
working-directory: ./e2e
env:
CI: true
run: npx playwright test
run: yarn workspace tidarr-testing playwright:test
- uses: actions/upload-artifact@v4
if: failure()
+13
View File
@@ -3,6 +3,19 @@ Tidarr notable changes.
[Keep a Changelog](http://keepachangelog.com/en/1.0.0/) format.
## 📦 1.0.7
⚠️ Docker volume path as changed. Beware to check your path in `docker-compose.yml` and `config.toml` tiddl congif files.
### 🚀 Added
- [API] Add `NO_DOWNLOAD` env var to use Tidarr UI only and process download with custom scripts. #517
- [API] Add persistant history separated from queue #512
### 🖍 Changed
- [Tiddl] Tidarr use now toml `scan_path` and `download_path` parameters
- [Docker] Update docker volume path to `/shared` and `/music`
- [Docker] Move `/home/app/standalone`to `/tidarr`
- [API] json-db: add cache for file reading
## 📦 1.0.6
### 🚀 Added
- [API] Add NTFY.sh notification service
+14 -13
View File
@@ -14,9 +14,7 @@ dev: ## Boot dev environnement
$(DOCKER_COMPOSE) up tidarr --build --remove-orphans
install: ## Install deps
$(DOCKER_COMPOSE) exec -w /home/app/standalone/api tidarr yarn install
$(DOCKER_COMPOSE) exec -w /home/app/standalone/app tidarr yarn install
$(DOCKER_COMPOSE) exec -w /home/app/standalone/e2e tidarr yarn install
$(DOCKER_COMPOSE) exec tidarr yarn install
##
## Playwright 🚨
##--------------
@@ -24,6 +22,9 @@ install: ## Install deps
testing-build: ## Build Tidarr production Docker image for E2E tests
docker build -t tidarr-prod --target production -f docker/Dockerfile .
testing-install: ## Install Playwright browsers
cd e2e && npx playwright install
testing-run: ## Run Playwright E2E tests (each test gets isolated container on random port) (arg: f=filter)
cd e2e && npx playwright test $(f)
@@ -44,20 +45,20 @@ testing-ui: ## Run local Playwright UI
##----------------
quality-deadcode: ## Fin deadcode with `ts-prune`
$(DOCKER_COMPOSE) exec -w /home/app/standalone/api tidarr yarn find-deadcode
$(DOCKER_COMPOSE) exec -w /home/app/standalone/app tidarr yarn find-deadcode
$(DOCKER_COMPOSE) exec -w /home/app/standalone/e2e tidarr yarn find-deadcode
$(DOCKER_COMPOSE) exec -w /tidarr/api tidarr yarn find-deadcode
$(DOCKER_COMPOSE) exec -w /tidarr/app tidarr yarn find-deadcode
$(DOCKER_COMPOSE) exec -w /tidarr/e2e tidarr yarn find-deadcode
quality-depcheck: ## Check dependencies
$(DOCKER_COMPOSE) exec -w /home/app/standalone/api tidarr yarn depcheck
$(DOCKER_COMPOSE) exec -w /home/app/standalone/app tidarr yarn depcheck
$(DOCKER_COMPOSE) exec -w /home/app/standalone/e2e tidarr yarn depcheck
$(DOCKER_COMPOSE) exec -w /tidarr/api tidarr yarn depcheck
$(DOCKER_COMPOSE) exec -w /tidarr/app tidarr yarn depcheck
$(DOCKER_COMPOSE) exec -w /tidarr/e2e tidarr yarn depcheck
quality-lint: ## Check dependencies
$(DOCKER_COMPOSE) exec -w /home/app/standalone tidarr yarn eslint
$(DOCKER_COMPOSE) exec -w /tidarr tidarr yarn eslint
quality-lint-fix: ## Check dependencies
$(DOCKER_COMPOSE) exec -w /home/app/standalone tidarr yarn eslint-fix
$(DOCKER_COMPOSE) exec -w /tidarr tidarr yarn eslint-fix
##
## Builder 🚀
@@ -71,8 +72,8 @@ docker-run: ## Run tidarr docker image
--rm \
--name tidarr \
-p 8484:8484 \
-v ${PWD}/docker/mnt/config:/home/app/standalone/shared \
-v ${PWD}/docker/mnt/library:/home/app/standalone/library \
-v ${PWD}/docker/mnt/config:/shared \
-v ${PWD}/docker/mnt/library:/music \
-e ENABLE_BEETS=true \
-e ENABLE_TIDAL_PROXY=true \
-e PUID=501 \
+30 -5
View File
@@ -26,6 +26,7 @@ Tidarr is a Docker image that provides a web interface to download up to **24-bi
- [M3U track base path](#m3u-track-base-path)
- [Sync playlists and mixes](#sync-playlists-and-mixes)
- [Custom CSS](#custom-css)
- [Download History](#download-history)
- [Services](#services):
- [Beets](#beets)
- [Plex/Plexamp](#plex-integration)
@@ -37,6 +38,7 @@ Tidarr is a Docker image that provides a web interface to download up to **24-bi
- [Webhook push over](#webhook-push-over)
- [Advanced](#advanced)
- [Custom Processing Script](#custom-processing-script)
- [No-download flag](#no-download)
- [API Documentation](#api-documentation)
- [User requests](#user-requests)
- [Donate](#donate)
@@ -105,8 +107,8 @@ services:
ports:
- 8484:8484
volumes:
- /any/folder/to/tidarr/config:/home/app/standalone/shared
- /any/folder/to/library:/home/app/standalone/library
- /any/folder/to/tidarr/config:/shared
- /any/folder/to/library:/music
restart: 'unless-stopped'
```
@@ -117,8 +119,8 @@ docker run \
--rm \
--name tidarr \
-p 8484:8484 \
-v /any/folder/to/tidarr/config:/home/app/standalone/shared \
-v /any/folder/to/library:/home/app/standalone/library \
-v /any/folder/to/tidarr/config:/shared \
-v /any/folder/to/library:/music \
cstaelen/tidarr:latest
```
@@ -232,6 +234,21 @@ You can customize Tidarr's appearance using the UI in settings dialog, or by edi
**File location**: `/your/docker/path/to/tidarr/config/custom.css`
### Download History
Track your downloaded items with the history feature. When enabled, Tidarr will maintain a list of all downloaded content and mark items as already downloaded in the UI.
```yaml
environment:
- ...
- ENABLE_HISTORY=true
```
**Features:**
- Persistent download tracking across restarts
- Visual indicators for already downloaded items (green checkmark)
- Manual history clearing available in settings dialog
## SERVICES
### Beets
@@ -375,10 +392,18 @@ Tidarr supports executing a custom shell script during the post-processing pipel
>
> 1. Create a shell script named `custom-script.sh` in your config folder (the mounted `shared/` volume)
> 2. The script will be automatically detected and executed during post-processing
> 3. The script runs **after** permissions are set but **before** files are moved to the library
> 3. The script runs **after** the tiddl download process (if not deactivated)
>
> To keep the benefits of post processing, all your files must be in the download folder using `PROCESSING_PATH` var available in `custom-script.sh`.
>
> 📖 [View complete API documentation](docs/CUSTOM_SCRIPT_DOCUMENTATION.md)
### NO DOWNLOAD
If you want to use Tidarr only as UI and not download files, you can set `NO_DOWNLOAD=true` in the environment variables.
This way you can use Tidarr to manage your download history, watchlist, and keep benefits of json DB (`sync_list.json`, `queue.json`) to manage download via custom scripts.
### API Documentation
If you want to interact with Tidarr from other applications (scripts, external services, automations), you can use the Express API.
+2 -3
View File
@@ -1,6 +1,5 @@
export const ROOT_PATH = "/home/app/standalone";
export const CONFIG_PATH = "/home/app/standalone/shared";
export const PROCESSING_PATH = "/home/app/standalone/shared/.processing";
export const ROOT_PATH = "/tidarr";
export const CONFIG_PATH = "/shared";
export const TIDAL_API_URL = "https://api.tidal.com";
export const SYNC_DEFAULT_CRON = "0 3 * * *";
export const TOKEN_REFRESH_THRESHOLD = 30 * 60; // Refresh token if expires in less than 30 minutes (1800 seconds)
+12 -2
View File
@@ -13,11 +13,13 @@ import { setupProxies } from "./src/proxies";
// Import routers
import authRouter from "./src/routes/auth";
import configRouter from "./src/routes/config";
import historyRouter from "./src/routes/history";
import processingRouter from "./src/routes/processing";
import sseRouter from "./src/routes/sse";
import syncRouter from "./src/routes/sync";
import tiddlTomlRouter from "./src/routes/tiddl-toml";
import { configureServer } from "./src/services/config";
import { loadHistoryFromFile } from "./src/services/history";
import { createCronJob } from "./src/services/sync";
import { startTokenRefreshInterval } from "./src/services/token-refresh";
@@ -43,11 +45,13 @@ app.use(cors());
// Setup all API proxies (Tidal, Plex, Navidrome)
setupProxies(app);
const processingList = ProcessingStack(app);
const processingList = ProcessingStack();
app.locals.processingStack = processingList;
app.locals.addOutputLog = processingList.actions.addOutputLog;
app.locals.activeListConnections = [];
app.locals.activeItemOutputConnections = new Map<string, Response[]>();
app.locals.history = [];
app.locals.historySet = new Set<string>();
app.all("/{*any}", function (_req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
@@ -67,6 +71,7 @@ app.use("/api", configRouter);
app.use("/api", syncRouter);
app.use("/api", customCssRouter);
app.use("/api", tiddlTomlRouter);
app.use("/api", historyRouter);
// Run
@@ -78,6 +83,11 @@ const server = app.listen(port, async () => {
const { config: tiddlConfig } = get_tiddl_config();
app.locals.tiddlConfig = tiddlConfig;
// Load download history
const history = await loadHistoryFromFile();
app.locals.history = history;
app.locals.historySet = new Set(history);
// Start token refresh interval (checks every 15 minutes)
startTokenRefreshInterval(app);
@@ -100,7 +110,7 @@ process.on("SIGINT", () => gracefulShutdown("SIGINT", app, server));
// fallback load app
const frontendFiles = "/home/app/standalone/app/build";
const frontendFiles = "/tidarr/app/build";
if (fs.existsSync(frontendFiles)) {
app.use(express.static(frontendFiles));
app.get("/{*any}", (_, res) => {
+4 -4
View File
@@ -2,10 +2,10 @@
echo "🕖 [TIDARR] Application loading ... "
SETTINGS_URL="/home/app/standalone/settings"
PUBLIC_URL="/home/app/standalone/app/build"
DEV_PUBLIC_URL="/home/app/standalone/app/public"
SHARED_URL="/home/app/standalone/shared"
SETTINGS_URL="/tidarr/settings"
PUBLIC_URL="/tidarr/build"
DEV_PUBLIC_URL="/tidarr/app/public"
SHARED_URL="/shared"
mkdir -p $SHARED_URL/.tiddl/ 2>&1 || {
echo "❌ [TIDDL] Failed to create .tiddl directory"
+53 -5
View File
@@ -1,16 +1,35 @@
import { queueDb } from "../services/db-json";
import { addItemToHistory } from "../services/history";
import { ProcessingItemType } from "../types";
const QUEUE_PATH = "/";
// In-memory cache to avoid disk reads
let queueCache: ProcessingItemType[] | null = null;
let queueCacheMap: Map<string, ProcessingItemType> | null = null;
/**
* Load queue from file (only on first call, then uses cache)
*/
export async function loadQueueFromFile(): Promise<ProcessingItemType[]> {
// Return cache if available
if (queueCache !== null) {
return queueCache;
}
// First load: read from disk
try {
const data = await queueDb.getData(QUEUE_PATH);
return Array.isArray(data) ? data : [];
queueCache = Array.isArray(data) ? data : [];
// Build Map for O(1) lookups
queueCacheMap = new Map(queueCache.map((item) => [item.id, item]));
return queueCache;
} catch {
// Database doesn't exist yet or path not found, initialize with empty array
await queueDb.push(QUEUE_PATH, []);
return [];
await queueDb.push(QUEUE_PATH, []); // auto-saves with saveOnPush=true
queueCache = [];
queueCacheMap = new Map();
return queueCache;
}
}
@@ -18,20 +37,32 @@ export const addItemToFile = async (item: ProcessingItemType) => {
const saveList = await loadQueueFromFile();
delete item.process;
saveList.push(item);
// Update cache
queueCache = saveList;
queueCacheMap?.set(item.id, item);
// Write to disk (auto-saves with saveOnPush=true)
await queueDb.push(QUEUE_PATH, saveList);
};
export const removeItemFromFile = async (id: string) => {
const saveList = await loadQueueFromFile();
const filteredList = saveList.filter((item) => item.id !== id);
// Update cache
queueCache = filteredList;
queueCacheMap?.delete(id);
// Write to disk (auto-saves with saveOnPush=true)
await queueDb.push(QUEUE_PATH, filteredList);
};
export const updateItemInQueueFile = async (item: ProcessingItemType) => {
const saveList = await loadQueueFromFile();
const itemIndex = saveList.findIndex((current) => current.id === item.id);
if (itemIndex === -1) {
// O(1) lookup using Map instead of O(n) findIndex
if (!queueCacheMap?.has(item.id)) {
// Item not found - it may have been removed already (e.g., auto-remove finished items)
// This is not an error, just skip the update
console.log(
@@ -40,7 +71,24 @@ export const updateItemInQueueFile = async (item: ProcessingItemType) => {
return;
}
const itemIndex = saveList.findIndex((current) => current.id === item.id);
delete item.process;
// If item is finished, move it to history
if (item.status === "finished" && process.env.ENABLE_HISTORY === "true") {
// Add to history
await addItemToHistory(item.id);
console.log(`[QUEUE] Item ${item.id} moved to history`);
}
// Keep in queue, just update
saveList[itemIndex] = { ...item };
// Update cache
queueCache = saveList;
queueCacheMap?.set(item.id, item);
// Write to disk (auto-saves with saveOnPush=true)
await queueDb.push(QUEUE_PATH, saveList);
};
+100 -114
View File
@@ -1,6 +1,6 @@
import { Express, Response } from "express";
import { PROCESSING_PATH } from "../../constants";
import { getAppInstance } from "../app-instance";
import { logs } from "../helpers/logs";
import {
addItemToFile,
@@ -16,12 +16,13 @@ import { ntfyPush } from "../services/ntfy";
import { plexUpdate } from "../services/plex";
import { hookPushOver } from "../services/pushover";
import { tidalDL } from "../services/tiddl";
import { ProcessingItemType, TiddlConfig } from "../types";
import { ProcessingItemType } from "../types";
import {
cleanFolder,
executeCustomScript,
getFolderToScan,
getProcessingPath,
hasFileToMove,
killProcess,
moveAndClean,
@@ -35,8 +36,8 @@ import {
getTracksByMixId,
} from "./mix-to-playlist";
export function notifySSEConnections(expressApp: Express) {
const { processingStack, activeListConnections } = expressApp.locals;
export function notifySSEConnections(app: Express) {
const { processingStack, activeListConnections } = app.locals;
// Data no longer contains output/output_history, send directly
const data = JSON.stringify(processingStack.data);
@@ -46,13 +47,9 @@ export function notifySSEConnections(expressApp: Express) {
});
}
export function notifyItemOutput(
expressApp: Express,
itemId: string,
output: string,
) {
export function notifyItemOutput(app: Express, itemId: string, output: string) {
const connections: Map<string, Response[]> =
expressApp.locals.activeItemOutputConnections;
app.locals.activeItemOutputConnections;
// Ensure itemId is a string for Map lookup
const itemIdString = String(itemId);
const itemConnections = connections.get(itemIdString);
@@ -73,8 +70,10 @@ export function notifyItemOutput(
}
}
export const ProcessingStack = (expressApp: Express) => {
export const ProcessingStack = () => {
const app: Express = getAppInstance();
const data: ProcessingItemType[] = [];
const dataMap: Map<string, ProcessingItemType> = new Map(); // O(1) item lookups
const outputs: Map<string, string[]> = new Map(); // Store terminal output as array of lines
let isPaused = false;
@@ -89,6 +88,7 @@ export const ProcessingStack = (expressApp: Express) => {
// Initialize empty output history for each loaded item (ensure string key)
outputs.set(String(record.id), []);
data.push(record);
dataMap.set(record.id, record);
});
if (!isPaused) {
@@ -123,39 +123,42 @@ export const ProcessingStack = (expressApp: Express) => {
// Notify connected clients about the output update
const currentOutput = getItemOutput(idString);
notifyItemOutput(expressApp, id, currentOutput);
notifyItemOutput(app, id, currentOutput);
}
async function addItem(item: ProcessingItemType) {
const foundIndex = data.findIndex(
(listItem: ProcessingItemType) => listItem?.id === item?.id,
);
if (foundIndex !== -1) return;
// O(1) lookup using Map instead of O(n) findIndex
if (dataMap.has(item.id)) {
await removeItem(item.id);
}
data.push(item);
dataMap.set(item.id, item);
await addItemToFile(item);
processQueue();
notifySSEConnections(expressApp);
notifySSEConnections(app);
}
async function removeItem(id: string) {
const item = getItem(id);
// O(1) lookup using Map
const item = dataMap.get(id);
if (!item) {
console.warn(`removeItem: Item ${id} not found in processing list`);
return;
}
const foundIndex = data.findIndex(
(listItem: ProcessingItemType) => listItem?.id === item?.id,
);
if (foundIndex === -1) {
console.warn(`removeItem: Item ${id} not found in processing list`);
return;
}
// Kill the process if it exists and is running
killProcess(item?.process, id);
delete data[foundIndex];
data.splice(foundIndex, 1);
dataMap.delete(id);
// Clean up output history for this item (ensure string key)
outputs.delete(String(id));
@@ -164,7 +167,7 @@ export const ProcessingStack = (expressApp: Express) => {
await removeItemFromFile(id);
processQueue();
notifySSEConnections(expressApp);
notifySSEConnections(app);
}
async function removeAllItems() {
@@ -180,7 +183,7 @@ export const ProcessingStack = (expressApp: Express) => {
async function removeFinishedItems() {
const itemsToRemove = data.filter((item) =>
["finished", "downloaded", "error"].includes(item.status),
["finished", "error"].includes(item.status),
);
for (const item of itemsToRemove) {
try {
@@ -192,53 +195,36 @@ export const ProcessingStack = (expressApp: Express) => {
}
function updateItem(item: ProcessingItemType) {
if (item?.status === "downloaded") {
try {
postProcessing(item);
} catch (error) {
// Catch any unhandled errors in postProcessing to prevent silent failures
const errorMessage =
error instanceof Error ? error.message : String(error);
logs(
item.id,
`❌ [TIDARR] Unexpected error during post-processing: ${errorMessage}`,
);
console.error(
`❌ [TIDARR] Post-processing error for item ${item.id}:`,
error,
);
item["status"] = "error";
// Update the item in the queue file to reflect error status
updateItemInQueueFile(item).catch((err) =>
console.error(
`❌ [TIDARR] Failed to update queue file for item ${item.id}:`,
err,
),
);
}
}
if (item?.status === "finished" || item?.status === "error") {
processQueue();
}
// Notify list connections about status changes (without output data)
notifySSEConnections(expressApp);
notifySSEConnections(app);
// Notify item-specific output connections with the latest output
const currentOutput = getItemOutput(item.id);
if (currentOutput) {
notifyItemOutput(expressApp, item.id, currentOutput);
notifyItemOutput(app, item.id, currentOutput);
}
}
function getItem(id: string): ProcessingItemType {
const foundIndex = data.findIndex(
(listItem: ProcessingItemType) => listItem?.id === id,
);
return data[foundIndex];
// O(1) lookup using Map instead of O(n) findIndex
return dataMap.get(id) as ProcessingItemType;
}
function processQueue(): void {
async function prepareProcessing(item: ProcessingItemType) {
item["status"] = "processing";
updateItem(item);
// Initialize empty output history in the Map (ensure string key)
outputs.set(String(item.id), []);
await cleanFolder(item.id);
}
async function processQueue(): Promise<void> {
if (isPaused) return;
const indexCurrent = data.findIndex(
@@ -250,7 +236,19 @@ export const ProcessingStack = (expressApp: Express) => {
if (indexCurrent !== -1) return;
if (indexNext !== -1) {
processItem(data[indexNext]);
const item = data[indexNext];
await prepareProcessing(item);
// If no_download=true skip processing.
if (app.locals.config.parameters?.NO_DOWNLOAD === "true") {
item["status"] = "no_download";
item["loading"] = false;
updateItem(item);
return;
}
processItem(item);
}
}
@@ -284,102 +282,90 @@ export const ProcessingStack = (expressApp: Express) => {
await cleanFolder(currentItem.id);
}
notifySSEConnections(expressApp);
notifySSEConnections(app);
}
function resumeQueue() {
isPaused = false;
processQueue(); // Restart the queue
notifySSEConnections(expressApp);
notifySSEConnections(app);
}
function getQueueStatus() {
return { isPaused };
}
async function processingMix(item: ProcessingItemType) {
const config = expressApp.locals.tiddlConfig as TiddlConfig;
const tracks = await getTracksByMixId(item, config);
const playlistId = await createNewPlaylist(item, config);
async function prepareMixToPlaylist(
item: ProcessingItemType,
): Promise<string | undefined> {
const tracks = await getTracksByMixId(item);
const playlistId = await createNewPlaylist(item);
if (tracks) {
await addTracksToPlaylist(playlistId, tracks, config, item.id);
item["url"] = `playlist/${playlistId}`;
logs(item.id, `🕖 [MIX]: Download temporary playlist`);
const child = tidalDL(item.id, expressApp, () => {
deletePlaylist(playlistId, config, item.id);
});
if (child) {
item["process"] = child;
}
return;
} else {
logs(item.id, `⚠️ [MIX]: No track found.`);
item["status"] = "error";
updateItem(item);
await addTracksToPlaylist(playlistId, tracks, item.id);
return playlistId;
}
deletePlaylist(playlistId, config, item.id);
logs(item.id, `⚠️ [MIX]: No track found.`);
item["status"] = "error";
updateItem(item);
deletePlaylist(playlistId, item.id);
}
async function processItem(item: ProcessingItemType) {
item["status"] = "processing";
// Initialize empty output history in the Map (ensure string key)
outputs.set(String(item.id), []);
updateItem(item);
await cleanFolder(item.id);
let playlistId;
if (item.type === "mix") {
processingMix(item);
} else {
const child = tidalDL(item.id, expressApp);
if (child) {
item["process"] = child;
}
const playlistId = await prepareMixToPlaylist(item);
item["url"] = `playlist/${playlistId}`;
}
const child = tidalDL(item.id, app, async () => {
if (playlistId) deletePlaylist(playlistId, item.id);
postProcessing(item);
return;
});
if (child) {
item["process"] = child;
}
}
async function postProcessing(item: ProcessingItemType) {
const shouldPostProcess = hasFileToMove(`${PROCESSING_PATH}/${item.id}`);
logs(item.id, "---------------------");
logs(item.id, "⚙️ POST PROCESSING ");
logs(item.id, "---------------------");
if (!shouldPostProcess) {
item["status"] = "finished";
updateItem(item);
logs(item.id, "✅ [TIDARR] No file to process.");
// Remove item from persistant queue file
await updateItemInQueueFile(item);
return;
}
if (item["status"] === "error") {
logs(item.id, "⚠️ [TIDDL] An error occured while downloading.");
return;
}
if (item["type"] === "playlist" || item["type"] === "mix") {
replacePathInM3U(item);
// Stop if there is no file to process (maybe existing)
const processingPath = getProcessingPath();
const shouldPostProcess = hasFileToMove(`${processingPath}/${item.id}`);
if (!shouldPostProcess) {
item["status"] = "finished";
updateItem(item);
logs(item.id, "✅ [TIDARR] No file to process.");
await updateItemInQueueFile(item);
return;
}
// Execute custom script if exists
await executeCustomScript(item);
// Update m3u item path
replacePathInM3U(item);
// Beets process
await beets(item.id);
// Set permissions
await setPermissions(item);
// Execute custom script if exists
await executeCustomScript(item);
// Keep trace of folders processed
const foldersToScan = getFolderToScan(item.id);
+32 -12
View File
@@ -2,23 +2,31 @@ import { ChildProcess, execSync, spawn } from "child_process";
import fs from "fs";
import path from "path";
import { CONFIG_PATH, PROCESSING_PATH, ROOT_PATH } from "../../constants";
import { CONFIG_PATH } from "../../constants";
import { getAppInstance } from "../app-instance";
import { logs } from "../helpers/logs";
import { ProcessingItemType } from "../types";
export function getProcessingPath(): string {
const app = getAppInstance();
const downloadPath = app.locals.tiddlConfig?.download.download_path;
// Remove trailing slash if present to avoid double slashes
return downloadPath?.replace(/\/+$/, "");
}
export async function moveAndClean(id: string): Promise<{
status: "finished" | "error" | undefined;
}> {
const app = getAppInstance();
const item: ProcessingItemType =
app.locals.processingStack.actions.getItem(id);
const processingPath = getProcessingPath();
let status: "finished" | "error" | undefined;
if (!item) return { status: "finished" };
const itemProcessingPath = `${PROCESSING_PATH}/${item.id}`;
const itemProcessingPath = `${processingPath}/${item.id}`;
const libraryPath = app.locals.tiddlConfig.download.scan_path;
try {
logs(item.id, "🕖 [TIDARR] Move processed items ...");
@@ -35,7 +43,7 @@ export async function moveAndClean(id: string): Promise<{
args = "-rfp";
}
const cmd = `cp ${args} "${itemProcessingPath}"/* "${ROOT_PATH}/library" >/dev/null`;
const cmd = `cp ${args} "${itemProcessingPath}"/* "${libraryPath}" >/dev/null`;
console.log(`🕖 [TIDARR] Command: ${cmd}`);
execSync(cmd, { encoding: "utf-8", shell: "/bin/sh" });
logs(item.id, `✅ [TIDARR] Move complete (${item.type})`);
@@ -58,9 +66,11 @@ export async function moveAndClean(id: string): Promise<{
export async function cleanFolder(
itemId?: string,
): Promise<"finished" | "error"> {
const processingPath = getProcessingPath();
const targetPath = itemId
? `${PROCESSING_PATH}/${itemId}`
: `${PROCESSING_PATH}/*`;
? `${processingPath}/${itemId}`
: `${processingPath}/*`;
// Check if target exists before attempting to remove
if (itemId) {
@@ -70,7 +80,7 @@ export async function cleanFolder(
}
} else {
// For wildcard cleanup, check if processing folder exists
if (!fs.existsSync(PROCESSING_PATH)) {
if (!fs.existsSync(processingPath)) {
return "finished";
}
}
@@ -91,7 +101,9 @@ export async function cleanFolder(
}
export function hasFileToMove(pathArg?: string): boolean {
const targetPath = pathArg || PROCESSING_PATH;
const processingPath = getProcessingPath();
const targetPath = pathArg || processingPath;
// Check if path exists first
if (!fs.existsSync(targetPath)) {
@@ -117,8 +129,11 @@ export function hasFileToMove(pathArg?: string): boolean {
}
export function replacePathInM3U(item: ProcessingItemType): void {
if (item["type"] !== "playlist" && item["type"] !== "mix") return;
const processingPath = getProcessingPath();
const basePath = process.env.M3U_BASEPATH_FILE || "./";
const downloadDir = `${PROCESSING_PATH}/${item.id}`;
const downloadDir = `${processingPath}/${item.id}`;
logs(item.id, `🕖 [TIDARR] Update track path in M3U file ...`);
@@ -151,7 +166,9 @@ export function replacePathInM3U(item: ProcessingItemType): void {
}
export async function setPermissions(item: ProcessingItemType) {
const itemProcessingPath = `${PROCESSING_PATH}/${item.id}`;
const processingPath = getProcessingPath();
const itemProcessingPath = `${processingPath}/${item.id}`;
if (process.env.PUID && process.env.PGID) {
try {
@@ -225,8 +242,10 @@ export async function setPermissions(item: ProcessingItemType) {
* @returns Array of parent folder paths relative to item's processing path that contain files to scan
*/
export function getFolderToScan(itemId: string): string[] {
const processingPath = getProcessingPath();
const foldersToScan: string[] = [];
const itemProcessingPath = `${PROCESSING_PATH}/${itemId}`;
const itemProcessingPath = `${processingPath}/${itemId}`;
try {
// Find all files (not directories) in the item's processing directory
@@ -325,8 +344,9 @@ export async function killProcess(
export async function executeCustomScript(
item: ProcessingItemType,
): Promise<void> {
const processingPath = getProcessingPath();
const customScriptPath = path.join(CONFIG_PATH, "custom-script.sh");
const itemProcessingPath = `${PROCESSING_PATH}/${item.id}`;
const itemProcessingPath = `${processingPath}/${item.id}`;
// Check if the custom script exists
if (!fs.existsSync(customScriptPath)) {
+22 -17
View File
@@ -1,10 +1,11 @@
import { getAppInstance } from "../app-instance";
import { logs } from "../helpers/logs";
import { ProcessingItemType, TiddlConfig } from "../types";
import { ProcessingItemType } from "../types";
export async function getTracksByMixId(item: ProcessingItemType) {
const app = getAppInstance();
const config = app.locals.config;
export async function getTracksByMixId(
item: ProcessingItemType,
config: TiddlConfig,
) {
if (!config?.auth) return;
logs(item.id, `🕖 [MIX]: Get track from mix id`);
@@ -38,10 +39,10 @@ export async function getTracksByMixId(
return ids;
}
export async function createNewPlaylist(
item: ProcessingItemType,
config: TiddlConfig,
) {
export async function createNewPlaylist(item: ProcessingItemType) {
const app = getAppInstance();
const config = app.locals.config;
if (!config?.auth) return;
logs(item.id, `🕖 [MIX]: Create new playlist`);
@@ -80,11 +81,10 @@ export async function createNewPlaylist(
return json.data.id;
}
export async function deletePlaylist(
playlistId: number,
config: TiddlConfig,
itemId: string,
) {
export async function deletePlaylist(playlistId: number, itemId: string) {
const app = getAppInstance();
const config = app.locals.config;
if (!config?.auth) return;
logs(itemId, `🕖 [MIX]: Delete temporary playlist`);
@@ -108,7 +108,10 @@ export async function deletePlaylist(
return response;
}
export async function getPlaylistEtag(playlistId: number, config: TiddlConfig) {
export async function getPlaylistEtag(playlistId: number) {
const app = getAppInstance();
const config = app.locals.config;
if (!config) return;
const playlistRes = await fetch(
@@ -137,14 +140,16 @@ export async function getPlaylistEtag(playlistId: number, config: TiddlConfig) {
export async function addTracksToPlaylist(
playlistId: number,
trackIds: number[],
config: TiddlConfig,
itemId: string,
) {
const app = getAppInstance();
const config = app.locals.config;
if (!config) return;
logs(itemId, `🕖 [MIX]: Add track ids to new playlist`);
const etag = await getPlaylistEtag(playlistId, config);
const etag = await getPlaylistEtag(playlistId);
const url = `https://tidal.com/v1/playlists/${playlistId}/items?countryCode=FR&locale=fr_FR&deviceType=BROWSER`;
const options: RequestInit = {
+44
View File
@@ -0,0 +1,44 @@
import { Request, Response, Router } from "express";
import { getAppInstance } from "../app-instance";
import { ensureAccessIsGranted } from "../helpers/auth";
import { handleRouteError } from "../helpers/error-handler";
import { flushHistory } from "../services/history";
const router = Router();
/**
* GET /api/history/list
* Get the download history
*/
router.get(
"/history/list",
ensureAccessIsGranted,
(_req: Request, res: Response) => {
try {
const app = getAppInstance();
res.json(app.locals.history);
} catch (error) {
handleRouteError(error, res, "get history");
}
},
);
/**
* DELETE /api/remove-all
* Clear the entire download queue
*/
router.delete(
"/history/list",
ensureAccessIsGranted,
(_req: Request, res: Response) => {
try {
flushHistory();
res.sendStatus(204);
} catch (error) {
handleRouteError(error, res, "remove all items from history");
}
},
);
export default router;
+4 -2
View File
@@ -1,8 +1,9 @@
import { spawnSync } from "child_process";
import { CONFIG_PATH, PROCESSING_PATH } from "../../constants";
import { CONFIG_PATH } from "../../constants";
import { getAppInstance } from "../app-instance";
import { logs } from "../helpers/logs";
import { getProcessingPath } from "../processing/jobs";
import { ProcessingItemType } from "../types";
function spawnBeet(
@@ -46,11 +47,12 @@ export async function beets(id: string): Promise<void> {
const app = getAppInstance();
const item: ProcessingItemType =
app.locals.processingStack.actions.getItem(id);
const processingPath = getProcessingPath();
if (!item || !["album", "artist", "favorite_albums"].includes(item.type))
return;
const itemProcessingPath = `${PROCESSING_PATH}/${item.id}`;
const itemProcessingPath = `${processingPath}/${item.id}`;
try {
// BEETS
+2
View File
@@ -51,6 +51,8 @@ export async function configureServer() {
ENABLE_TIDAL_PROXY: process.env.ENABLE_TIDAL_PROXY || "",
SYNC_CRON_EXPRESSION:
process.env.SYNC_CRON_EXPRESSION || SYNC_DEFAULT_CRON || "",
NO_DOWNLOAD: process.env.NO_DOWNLOAD || "",
ENABLE_HISTORY: process.env.ENABLE_HISTORY || "",
},
};
} catch (error: unknown) {
+7 -1
View File
@@ -3,10 +3,16 @@ import path from "path";
import { CONFIG_PATH } from "../../constants";
// Queue database
// Queue database (saveOnPush enabled, cache used to avoid disk reads)
const queueDbPath = path.join(CONFIG_PATH, "queue");
export const queueDb = new JsonDB(new Config(queueDbPath, true, false, "/"));
// History database (indexing disabled to preserve array type at root)
const historyDbPath = path.join(CONFIG_PATH, "history");
export const historyDb = new JsonDB(
new Config(historyDbPath, true, false, "/"),
);
// Sync list database
const syncListDbPath = path.join(CONFIG_PATH, "sync_list");
export const syncListDb = new JsonDB(
+57
View File
@@ -0,0 +1,57 @@
import { getAppInstance } from "../app-instance";
import { historyDb } from "../services/db-json";
const QUEUE_PATH = "/";
export async function loadHistoryFromFile(): Promise<string[]> {
if (process.env.ENABLE_HISTORY !== "true") {
return [];
}
try {
const data = await historyDb.getData(QUEUE_PATH);
console.log("✅ [HISTORY] Processing history loaded.");
// Convert numeric-keyed object to array of values
return Array.isArray(data) ? data : Object.values(data);
} catch {
// Database doesn't exist yet or path not found, initialize with empty array
await historyDb.push(QUEUE_PATH, []);
return [];
}
}
export async function addItemToHistory(itemId: string) {
if (process.env.ENABLE_HISTORY !== "true") {
return;
}
const app = getAppInstance();
const id = itemId.toString();
// O(1) lookup using Set instead of O(n) array.includes()
if (app.locals.historySet.has(id)) {
return;
}
app.locals.history.push(id);
app.locals.historySet.add(id);
// Write only the new item at the end of the array (more efficient than rewriting entire file)
const newIndex = app.locals.history.length - 1;
await historyDb.push(`/${newIndex}`, id, false);
console.log(`✅ [HISTORY] Item "${id}" added to history.`);
}
export async function flushHistory() {
if (process.env.ENABLE_HISTORY !== "true") {
return;
}
const app = getAppInstance();
app.locals.history = [];
app.locals.historySet.clear();
await historyDb.push(QUEUE_PATH, []);
console.log("🚽 [HISTORY] History has been flushed.");
}
+38 -6
View File
@@ -8,14 +8,28 @@ import { syncListDb } from "./db-json";
const SYNC_LIST_PATH = "/";
// In-memory cache to avoid disk reads
let syncListCache: SyncItemType[] | null = null;
/**
* Load sync list from file (only on first call, then uses cache)
*/
const loadSyncList = async (): Promise<SyncItemType[]> => {
// Return cache if available
if (syncListCache !== null) {
return syncListCache;
}
// First load: read from disk
try {
const data = await syncListDb.getData(SYNC_LIST_PATH);
return Array.isArray(data) ? data : [];
syncListCache = Array.isArray(data) ? data : [];
return syncListCache;
} catch {
// Database doesn't exist yet or path not found, initialize with empty array
await syncListDb.push(SYNC_LIST_PATH, []);
return [];
await syncListDb.push(SYNC_LIST_PATH, []); // auto-saves with saveOnPush=true
syncListCache = [];
return syncListCache;
}
};
@@ -30,6 +44,11 @@ export const addItemToSyncList = async (item: SyncItemType) => {
if (itemExists) return;
syncList.push(item);
// Update cache
syncListCache = syncList;
// Write to disk (auto-saves with saveOnPush=true)
await syncListDb.push(SYNC_LIST_PATH, syncList);
};
@@ -40,10 +59,18 @@ export const removeItemFromSyncList = async (id: number | string) => {
(item) => item.id.toString() !== idString,
);
// Update cache
syncListCache = filteredList;
// Write to disk (auto-saves with saveOnPush=true)
await syncListDb.push(SYNC_LIST_PATH, filteredList);
};
export const removeAllFromSyncList = async () => {
// Update cache
syncListCache = [];
// Write to disk (auto-saves with saveOnPush=true)
await syncListDb.push(SYNC_LIST_PATH, []);
};
@@ -56,6 +83,11 @@ export const updateSyncItem = async (
if (itemIndex !== -1) {
syncList[itemIndex] = { ...syncList[itemIndex], ...update };
// Update cache
syncListCache = syncList;
// Write to disk (auto-saves with saveOnPush=true)
await syncListDb.push(SYNC_LIST_PATH, syncList);
}
};
@@ -75,7 +107,7 @@ export const process_sync_list = async (app: Express) => {
element.id,
);
if (item && ["processing"].includes(item?.status)) continue;
if (item && ["finished", "downloaded"].includes(item?.status)) {
if (item && ["finished", "no_download"].includes(item?.status)) {
await app.locals.processingStack.actions.removeItem(element.id);
}
@@ -127,10 +159,10 @@ export const createCronJob = async (app: Express) => {
try {
cron.schedule(
finalExpression,
() => {
async () => {
try {
// Read the sync list fresh each time the cron runs
process_sync_list(app);
await process_sync_list(app);
} catch (callbackError) {
console.error("❌ [SYNC] Error in cron callback:", callbackError);
}
+7 -3
View File
@@ -5,6 +5,7 @@ import { CONFIG_PATH, TOKEN_REFRESH_THRESHOLD } from "../../constants";
import { extractFirstLineClean } from "../helpers/ansi_parse";
import { get_tiddl_config } from "../helpers/get_tiddl_config";
import { logs } from "../helpers/logs";
import { getProcessingPath } from "../processing/jobs";
import { ProcessingItemType, TiddlConfig } from "../types";
// Constants
@@ -44,8 +45,10 @@ export function tidalDL(id: string, app: Express, onFinish?: () => void) {
const args: string[] = [];
args.push("download");
args.push("--path", `${CONFIG_PATH}/.processing/${item.id}`);
args.push("--scan-path", "/home/app/standalone/library");
// Use getProcessingPath() to ensure trailing slashes are handled
const processingPath = getProcessingPath();
args.push("--path", `${processingPath}/${item.id}`);
if (item.type === "mix" && config?.templates?.mix) {
args.push("-o", config.templates.mix);
@@ -149,7 +152,8 @@ export function tidalDL(id: string, app: Express, onFinish?: () => void) {
}
item["status"] =
isDownloaded && !hasProcessingError ? "downloaded" : "error";
!isDownloaded || hasProcessingError ? "error" : item["status"];
item["loading"] = false;
app.locals.processingStack.actions.updateItem(item);
if (onFinish) onFinish();
+2 -1
View File
@@ -20,7 +20,7 @@ export type ProcessingItemType = {
artist: string;
title: string;
type: ContentType;
status: "queue" | "finished" | "downloaded" | "processing" | "error";
status: "queue" | "finished" | "processing" | "error" | "no_download";
quality: QualityType;
url: string;
loading: boolean;
@@ -158,6 +158,7 @@ export interface SettingsResponse {
LOCK_QUALITY?: string;
ENABLE_TIDAL_PROXY?: string;
SYNC_CRON_EXPRESSION?: string;
ENABLE_HISTORY?: string;
};
noToken: boolean;
tiddl_config?: TiddlConfig;
-780
View File
@@ -1,780 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@types/apicache@^1.6.8":
version "1.6.8"
resolved "https://registry.yarnpkg.com/@types/apicache/-/apicache-1.6.8.tgz#d2dbb0eefc019407dcb629e5637efb7dd9925dda"
integrity sha512-PDBHDA9p/YEYRLL2KN2kr2UXRAWvZq/WM+GBN94MwPQRZORUnoazd1A1plZXWDOvCvGTgcKriRIIECnRpHhC/g==
dependencies:
"@types/express" "*"
"@types/redis" "^2.8.0"
"@types/body-parser@*":
version "1.19.5"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4"
integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==
dependencies:
"@types/connect" "*"
"@types/node" "*"
"@types/connect@*":
version "3.4.38"
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858"
integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==
dependencies:
"@types/node" "*"
"@types/cors@^2.8.19":
version "2.8.19"
resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.19.tgz#d93ea2673fd8c9f697367f5eeefc2bbfa94f0342"
integrity sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==
dependencies:
"@types/node" "*"
"@types/express-http-proxy@^1.6.7":
version "1.6.7"
resolved "https://registry.yarnpkg.com/@types/express-http-proxy/-/express-http-proxy-1.6.7.tgz#7536a8dbaa3bbbfc4c5d3dc519a2295822e6150b"
integrity sha512-CEp9pbnwVI1RzN9PXc+KESMxwUW5r1O7tkWb5h7Wg/YAIf+KulD/zKev8fbbn+Ljt0Yvs8MXwV2W6Id+cKxe2Q==
dependencies:
"@types/express" "*"
"@types/express-serve-static-core@^5.0.0":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.0.2.tgz#812d2871e5eea17fb0bd5214dda7a7b748c0e12a"
integrity sha512-vluaspfvWEtE4vcSDlKRNer52DvOGrB2xv6diXy6UKyKW0lqZiWHGNApSyxOv+8DE5Z27IzVvE7hNkxg7EXIcg==
dependencies:
"@types/node" "*"
"@types/qs" "*"
"@types/range-parser" "*"
"@types/send" "*"
"@types/express@*", "@types/express@^5.0.5":
version "5.0.5"
resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.5.tgz#3ba069177caa34ab96585ca23b3984d752300cdc"
integrity sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==
dependencies:
"@types/body-parser" "*"
"@types/express-serve-static-core" "^5.0.0"
"@types/serve-static" "^1"
"@types/http-errors@*":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f"
integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==
"@types/jsonwebtoken@^9.0.10":
version "9.0.10"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz#a7932a47177dcd4283b6146f3bd5c26d82647f09"
integrity sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==
dependencies:
"@types/ms" "*"
"@types/node" "*"
"@types/mime@^1":
version "1.3.5"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690"
integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==
"@types/ms@*":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78"
integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
"@types/node@*", "@types/node@^24.10.0":
version "24.10.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.0.tgz#6b79086b0dfc54e775a34ba8114dcc4e0221f31f"
integrity sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==
dependencies:
undici-types "~7.16.0"
"@types/qs@*":
version "6.9.17"
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.17.tgz#fc560f60946d0aeff2f914eb41679659d3310e1a"
integrity sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==
"@types/range-parser@*":
version "1.2.7"
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
"@types/redis@^2.8.0":
version "2.8.32"
resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.32.tgz#1d3430219afbee10f8cfa389dad2571a05ecfb11"
integrity sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==
dependencies:
"@types/node" "*"
"@types/send@*":
version "0.17.4"
resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a"
integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==
dependencies:
"@types/mime" "^1"
"@types/node" "*"
"@types/send@<1":
version "0.17.6"
resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.6.tgz#aeb5385be62ff58a52cd5459daa509ae91651d25"
integrity sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==
dependencies:
"@types/mime" "^1"
"@types/node" "*"
"@types/serve-static@^1":
version "1.15.10"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.10.tgz#768169145a778f8f5dfcb6360aead414a3994fee"
integrity sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==
dependencies:
"@types/http-errors" "*"
"@types/node" "*"
"@types/send" "<1"
accepts@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895"
integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==
dependencies:
mime-types "^3.0.0"
negotiator "^1.0.0"
apicache@^1.6.3:
version "1.6.3"
resolved "https://registry.yarnpkg.com/apicache/-/apicache-1.6.3.tgz#4441aa40f2a48c9dd6f11c73be937d563405355b"
integrity sha512-jS3VfUFpQ9BesFQZcdd1vVYg3ZsO2kGPmTJHqycIYPAQs54r74CRiyj8DuzJpwzLwIfCBYzh4dy9Jt8xYbo27w==
body-parser@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.0.tgz#f7a9656de305249a715b549b7b8fd1ab9dfddcfa"
integrity sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==
dependencies:
bytes "^3.1.2"
content-type "^1.0.5"
debug "^4.4.0"
http-errors "^2.0.0"
iconv-lite "^0.6.3"
on-finished "^2.4.1"
qs "^6.14.0"
raw-body "^3.0.0"
type-is "^2.0.0"
buffer-equal-constant-time@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==
bytes@3.1.2, bytes@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
dependencies:
es-errors "^1.3.0"
function-bind "^1.1.2"
call-bound@^1.0.2:
version "1.0.4"
resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a"
integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==
dependencies:
call-bind-apply-helpers "^1.0.2"
get-intrinsic "^1.3.0"
content-disposition@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.0.tgz#844426cb398f934caefcbb172200126bc7ceace2"
integrity sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==
dependencies:
safe-buffer "5.2.1"
content-type@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
cookie-signature@^1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793"
integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==
cookie@^0.7.1:
version "0.7.2"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
cors@^2.8.5:
version "2.8.5"
resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
dependencies:
object-assign "^4"
vary "^1"
debug@^3.0.1:
version "3.2.7"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
dependencies:
ms "^2.1.1"
debug@^4.3.5, debug@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a"
integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==
dependencies:
ms "^2.1.3"
depd@2.0.0, depd@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
dotenv@^17.2.3:
version "17.2.3"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.2.3.tgz#ad995d6997f639b11065f419a22fabf567cdb9a2"
integrity sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==
dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
dependencies:
call-bind-apply-helpers "^1.0.1"
es-errors "^1.3.0"
gopd "^1.2.0"
ecdsa-sig-formatter@1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
dependencies:
safe-buffer "^5.0.1"
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
encodeurl@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
es-define-property@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
es-errors@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
es-object-atoms@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941"
integrity sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==
dependencies:
es-errors "^1.3.0"
es-object-atoms@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1"
integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==
dependencies:
es-errors "^1.3.0"
es6-promise@^4.1.1:
version "4.2.8"
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
escape-html@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
etag@^1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
express-http-proxy@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/express-http-proxy/-/express-http-proxy-2.1.2.tgz#ec577539aa23c9354bfa4ae43e8a318923263c55"
integrity sha512-FXcAcs7Nf/hF73Mzh0WDWPwaOlsEUL/fCHW3L4wU6DH79dypsaxmbnAildCLniFs7HQuuvoiR6bjNVUvGuTb5g==
dependencies:
debug "^3.0.1"
es6-promise "^4.1.1"
raw-body "^2.3.0"
express@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/express/-/express-5.1.0.tgz#d31beaf715a0016f0d53f47d3b4d7acf28c75cc9"
integrity sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==
dependencies:
accepts "^2.0.0"
body-parser "^2.2.0"
content-disposition "^1.0.0"
content-type "^1.0.5"
cookie "^0.7.1"
cookie-signature "^1.2.1"
debug "^4.4.0"
encodeurl "^2.0.0"
escape-html "^1.0.3"
etag "^1.8.1"
finalhandler "^2.1.0"
fresh "^2.0.0"
http-errors "^2.0.0"
merge-descriptors "^2.0.0"
mime-types "^3.0.0"
on-finished "^2.4.1"
once "^1.4.0"
parseurl "^1.3.3"
proxy-addr "^2.0.7"
qs "^6.14.0"
range-parser "^1.2.1"
router "^2.2.0"
send "^1.1.0"
serve-static "^2.2.0"
statuses "^2.0.1"
type-is "^2.0.1"
vary "^1.1.2"
finalhandler@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.1.0.tgz#72306373aa89d05a8242ed569ed86a1bff7c561f"
integrity sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==
dependencies:
debug "^4.4.0"
encodeurl "^2.0.0"
escape-html "^1.0.3"
on-finished "^2.4.1"
parseurl "^1.3.3"
statuses "^2.0.1"
forwarded@0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
fresh@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4"
integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
get-intrinsic@^1.2.5, get-intrinsic@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
dependencies:
call-bind-apply-helpers "^1.0.2"
es-define-property "^1.0.1"
es-errors "^1.3.0"
es-object-atoms "^1.1.1"
function-bind "^1.1.2"
get-proto "^1.0.1"
gopd "^1.2.0"
has-symbols "^1.1.0"
hasown "^2.0.2"
math-intrinsics "^1.1.0"
get-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
dependencies:
dunder-proto "^1.0.1"
es-object-atoms "^1.0.0"
gopd@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
has-symbols@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
hasown@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003"
integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
dependencies:
function-bind "^1.1.2"
http-errors@2.0.0, http-errors@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
dependencies:
depd "2.0.0"
inherits "2.0.4"
setprototypeof "1.2.0"
statuses "2.0.1"
toidentifier "1.0.1"
iconv-lite@0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
dependencies:
safer-buffer ">= 2.1.2 < 3"
iconv-lite@0.6.3, iconv-lite@^0.6.3:
version "0.6.3"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
inherits@2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
ipaddr.js@1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
is-promise@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3"
integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==
jsonwebtoken@^9.0.2:
version "9.0.2"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3"
integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==
dependencies:
jws "^3.2.2"
lodash.includes "^4.3.0"
lodash.isboolean "^3.0.3"
lodash.isinteger "^4.0.4"
lodash.isnumber "^3.0.3"
lodash.isplainobject "^4.0.6"
lodash.isstring "^4.0.1"
lodash.once "^4.0.0"
ms "^2.1.1"
semver "^7.5.4"
jwa@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==
dependencies:
buffer-equal-constant-time "1.0.1"
ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1"
jws@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
dependencies:
jwa "^1.4.1"
safe-buffer "^5.0.1"
lodash.includes@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==
lodash.isboolean@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==
lodash.isinteger@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==
lodash.isnumber@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==
lodash.isplainobject@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==
lodash.isstring@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==
lodash.once@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
media-typer@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561"
integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==
merge-descriptors@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808"
integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==
mime-db@^1.54.0:
version "1.54.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5"
integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==
mime-types@^3.0.0, mime-types@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.1.tgz#b1d94d6997a9b32fd69ebaed0db73de8acb519ce"
integrity sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==
dependencies:
mime-db "^1.54.0"
ms@^2.1.1, ms@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
negotiator@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a"
integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==
node-cron@^4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/node-cron/-/node-cron-4.2.1.tgz#6979be4aee4702f06322d21220df8de252c8e265"
integrity sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==
object-assign@^4:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
object-inspect@^1.13.3:
version "1.13.3"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.3.tgz#f14c183de51130243d6d18ae149375ff50ea488a"
integrity sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==
on-finished@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
dependencies:
ee-first "1.1.1"
once@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
dependencies:
wrappy "1"
parseurl@^1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
path-to-regexp@^8.0.0:
version "8.2.0"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4"
integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==
proxy-addr@^2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
dependencies:
forwarded "0.2.0"
ipaddr.js "1.9.1"
qs@^6.14.0:
version "6.14.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930"
integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==
dependencies:
side-channel "^1.1.0"
range-parser@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
raw-body@^2.3.0:
version "2.5.2"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a"
integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==
dependencies:
bytes "3.1.2"
http-errors "2.0.0"
iconv-lite "0.4.24"
unpipe "1.0.0"
raw-body@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f"
integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==
dependencies:
bytes "3.1.2"
http-errors "2.0.0"
iconv-lite "0.6.3"
unpipe "1.0.0"
router@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef"
integrity sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==
dependencies:
debug "^4.4.0"
depd "^2.0.0"
is-promise "^4.0.0"
parseurl "^1.3.3"
path-to-regexp "^8.0.0"
safe-buffer@5.2.1, safe-buffer@^5.0.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0":
version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
semver@^7.5.4:
version "7.6.3"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143"
integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==
send@^1.1.0, send@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/send/-/send-1.2.0.tgz#32a7554fb777b831dfa828370f773a3808d37212"
integrity sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==
dependencies:
debug "^4.3.5"
encodeurl "^2.0.0"
escape-html "^1.0.3"
etag "^1.8.1"
fresh "^2.0.0"
http-errors "^2.0.0"
mime-types "^3.0.1"
ms "^2.1.3"
on-finished "^2.4.1"
range-parser "^1.2.1"
statuses "^2.0.1"
serve-static@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.2.0.tgz#9c02564ee259bdd2251b82d659a2e7e1938d66f9"
integrity sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==
dependencies:
encodeurl "^2.0.0"
escape-html "^1.0.3"
parseurl "^1.3.3"
send "^1.2.0"
setprototypeof@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
side-channel-list@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==
dependencies:
es-errors "^1.3.0"
object-inspect "^1.13.3"
side-channel-map@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42"
integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==
dependencies:
call-bound "^1.0.2"
es-errors "^1.3.0"
get-intrinsic "^1.2.5"
object-inspect "^1.13.3"
side-channel-weakmap@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea"
integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==
dependencies:
call-bound "^1.0.2"
es-errors "^1.3.0"
get-intrinsic "^1.2.5"
object-inspect "^1.13.3"
side-channel-map "^1.0.1"
side-channel@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9"
integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==
dependencies:
es-errors "^1.3.0"
object-inspect "^1.13.3"
side-channel-list "^1.0.0"
side-channel-map "^1.0.1"
side-channel-weakmap "^1.0.2"
statuses@2.0.1, statuses@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
toidentifier@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
toml@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/toml/-/toml-3.0.0.tgz#342160f1af1904ec9d204d03a5d61222d762c5ee"
integrity sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==
type-is@^2.0.0, type-is@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.1.tgz#64f6cf03f92fce4015c2b224793f6bdd4b068c97"
integrity sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==
dependencies:
content-type "^1.0.5"
media-typer "^1.1.0"
mime-types "^3.0.0"
undici-types@~7.16.0:
version "7.16.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
unpipe@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
vary@^1, vary@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
+43 -8
View File
@@ -1,10 +1,13 @@
import { useMemo } from "react";
import { Block } from "@mui/icons-material";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import CheckIcon from "@mui/icons-material/Check";
import DownloadIcon from "@mui/icons-material/Download";
import WarningIcon from "@mui/icons-material/Warning";
import { Button, CircularProgress } from "@mui/material";
import { TIDAL_ALBUM_URL, TIDAL_MIX_URL } from "src/contants";
import { useConfigProvider } from "src/provider/ConfigProvider";
import { useHistoryProvider } from "src/provider/HistoryProvider";
import { useProcessingProvider } from "../../provider/ProcessingProvider";
import {
@@ -29,18 +32,47 @@ export const DownloadButton = ({
force?: boolean;
}) => {
const { processingList, actions } = useProcessingProvider();
const { config } = useConfigProvider();
const {
history,
actions: { addToHistory },
} = useHistoryProvider();
const status = useMemo(() => {
if (!processingList || processingList?.length === 0 || !id) {
return undefined;
}
const index = processingList.findIndex(
// Check if item is currently in the processing queue
const processingItem = processingList?.find(
(x) => x.id?.toString() === id?.toString() && x.type === type,
);
return index > -1 ? processingList?.[index]?.status : undefined;
}, [processingList, id, type]);
if (processingItem) {
const itemStatus = processingItem.status;
// When item finishes and history is enabled, add to local history
if (itemStatus === "finished" && config?.ENABLE_HISTORY === "true") {
addToHistory(id.toString());
}
return itemStatus;
}
// If history is enabled, check local history
if (config?.ENABLE_HISTORY === "true" && !force) {
const isInHistory = history?.some(
(x) => x?.toString() === id?.toString(),
);
return isInHistory ? "finished" : undefined;
}
return undefined;
}, [
processingList,
force,
config?.ENABLE_HISTORY,
history,
id,
type,
addToHistory,
]);
const downloadItem = async () => {
if (force) await actions.removeItem(id);
@@ -74,6 +106,7 @@ export const DownloadButton = ({
<Button
variant="outlined"
data-testid="btn-dl"
color={status === "finished" ? "success" : "primary"}
endIcon={
status === "queue" ? (
<AccessTimeIcon />
@@ -83,11 +116,13 @@ export const DownloadButton = ({
<CheckIcon color="success" />
) : status === "processing" ? (
<CircularProgress size={20} />
) : status === "no_download" ? (
<Block color="disabled" />
) : (
<DownloadIcon />
)
}
disabled={!!status && !force}
disabled={!!status && status !== "finished" && !force}
onClick={() => downloadItem()}
size="small"
>
+29 -26
View File
@@ -2,6 +2,7 @@ import { ReactNode, useEffect, useState } from "react";
import styled from "@emotion/styled";
import AlbumIcon from "@mui/icons-material/Album";
import { AppBar, CircularProgress } from "@mui/material";
import { HistoryProvider } from "src/provider/HistoryProvider";
import { SearchProvider } from "src/provider/SearchProvider";
import { SyncProvider } from "src/provider/SyncProvider";
@@ -46,32 +47,34 @@ function MainLayout({ children }: { children: ReactNode }) {
style={{ paddingBottom: "1rem" }}
>
<SearchProvider>
<ProcessingProvider>
<SyncProvider>
<Content>
<AppBar id="app-bar" position="sticky">
<HeaderSearch />
</AppBar>
{!appLoaded || !config ? (
<Loader>
<Title>
<AlbumIcon />
Tidarr
</Title>
<CircularProgress />
</Loader>
) : (
children
)}
</Content>
<ProcessingList />
<DialogToken />
<DialogNoAPI />
<DialogConfigError />
<DialogConfig />
<DialogUpdateWarning />
</SyncProvider>
</ProcessingProvider>
<HistoryProvider>
<ProcessingProvider>
<SyncProvider>
<Content>
<AppBar id="app-bar" position="sticky">
<HeaderSearch />
</AppBar>
{!appLoaded || !config ? (
<Loader>
<Title>
<AlbumIcon />
Tidarr
</Title>
<CircularProgress />
</Loader>
) : (
children
)}
</Content>
<ProcessingList />
<DialogToken />
<DialogNoAPI />
<DialogConfigError />
<DialogConfig />
<DialogUpdateWarning />
</SyncProvider>
</ProcessingProvider>
</HistoryProvider>
</SearchProvider>
</main>
<Footer />
+57 -25
View File
@@ -1,8 +1,15 @@
import { useState } from "react";
import { EditDocument, KeyOff, Warning } from "@mui/icons-material";
import {
Cached,
Check,
EditDocument,
KeyOff,
Warning,
} from "@mui/icons-material";
import { Alert, Box, Button } from "@mui/material";
import { useApiFetcher } from "src/provider/ApiFetcherProvider";
import { useConfigProvider } from "src/provider/ConfigProvider";
import { useHistoryProvider } from "src/provider/HistoryProvider";
import TiddlConfigEdit from "./tidal/TiddlConfigEdit";
import TiddlConfigList from "./tidal/TiddlConfigList";
@@ -10,14 +17,21 @@ import TiddlConfigList from "./tidal/TiddlConfigList";
export default function TidalPanel() {
const { tokenMissing, actions } = useConfigProvider();
const [showEditor, setShowEditor] = useState<boolean>();
const [historyFlushed, setHistoryFlushed] = useState<boolean>();
const {
config,
actions: { checkAPI },
} = useConfigProvider();
const {
actions: { delete_token },
} = useApiFetcher();
const {
actions: { emptyHistory },
} = useHistoryProvider();
return (
<>
{tokenMissing && (
@@ -32,34 +46,52 @@ export default function TidalPanel() {
</Box>
)}
<Box component="h2" display="flex" flexWrap="wrap">
<Box flex="1 1 0" component="span">
Tiddl configuration
</Box>
<Box flex="0 0 auto" component="span" display="flex" gap={2}>
<Box component="h2">Tiddl configuration</Box>
<Box
flex="0 0 auto"
component="span"
display="flex"
gap={2}
mb={3}
flexWrap="wrap"
>
<Button
variant="contained"
color="primary"
startIcon={<EditDocument />}
onClick={() => setShowEditor(!showEditor)}
>
Toggle editor
</Button>
{!tokenMissing && (
<Button
variant="contained"
color="primary"
startIcon={<EditDocument />}
onClick={() => setShowEditor(!showEditor)}
color="warning"
startIcon={<KeyOff />}
onClick={async () => {
actions.toggleModal(false);
await delete_token();
checkAPI();
}}
>
Toggle editor
Revoke Tidal token
</Button>
{!tokenMissing && (
<Button
variant="contained"
color="warning"
startIcon={<KeyOff />}
onClick={async () => {
actions.toggleModal(false);
await delete_token();
checkAPI();
}}
>
Revoke Tidal token
</Button>
)}
</Box>
)}
{config?.ENABLE_HISTORY && (
<Button
variant="contained"
color="error"
disabled={!!historyFlushed}
startIcon={historyFlushed ? <Check /> : <Cached />}
onClick={async () => {
setHistoryFlushed(true);
await emptyHistory();
window.location.reload();
}}
>
Empty history
</Button>
)}
</Box>
{showEditor ? <TiddlConfigEdit /> : <TiddlConfigList />}
</>
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom";
import styled from "@emotion/styled";
import { Block } from "@mui/icons-material";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import CheckIcon from "@mui/icons-material/Check";
import ClearIcon from "@mui/icons-material/Clear";
@@ -17,7 +18,7 @@ import { ProcessingItemType } from "src/types";
import { DialogTerminal } from "../Dialog/DialogTerminal";
export const ProcessingItem = ({ item }: { item: ProcessingItemType }) => {
const step = item?.status;
const status = item?.status;
const { actions } = useProcessingProvider();
if (!item?.status) return null;
@@ -33,16 +34,18 @@ export const ProcessingItem = ({ item }: { item: ProcessingItemType }) => {
<RemoveButton onClick={() => actions.removeItem(item.id)}>
<ClearIcon />
</RemoveButton>
{step === "finished" ? (
{status === "finished" ? (
<CheckIcon color="success" />
) : step === "error" ? (
) : status === "error" ? (
<WarningIcon color="error" />
) : step === "queue" ? (
) : status === "queue" ? (
<AccessTimeIcon />
) : status === "no_download" ? (
<Block color="disabled" />
) : (
<CircularProgress size={20} />
)}
{step === "error" ? (
{status === "error" ? (
<>
&nbsp;&nbsp;
<Button variant="outlined" size="small" onClick={() => run()}>
@@ -51,8 +54,10 @@ export const ProcessingItem = ({ item }: { item: ProcessingItemType }) => {
</>
) : null}
&nbsp;&nbsp;
{item.status !== "queue" && <DialogTerminal item={item} />}
{step}
{item.status !== "queue" && item.status !== "no_download" && (
<DialogTerminal item={item} />
)}
{status}
</Box>
</TableCell>
<TableCell scope="row">
@@ -13,6 +13,7 @@ import {
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { blue } from "@mui/material/colors";
import { useApiFetcher } from "src/provider/ApiFetcherProvider";
@@ -24,6 +25,7 @@ import { ProcessingPauseButton } from "./ProcessingPauseButton";
export const ProcessingList = () => {
const { processingList, isPaused } = useProcessingProvider();
const { config } = useConfigProvider();
const { actions } = useConfigProvider();
const { actions: apiActions } = useApiFetcher();
const [open, setOpen] = useState(false);
@@ -77,7 +79,7 @@ export const ProcessingList = () => {
case hasError:
buttonColor = "error";
break;
case isLoading:
case isLoading || config?.NO_DOWNLOAD === "true":
buttonColor = "primary";
break;
default:
@@ -173,7 +175,15 @@ export const ProcessingList = () => {
justifyContent="space-between"
alignItems="center"
>
<ProcessingPauseButton />
<div>
{config?.NO_DOWNLOAD !== "true" ? (
<ProcessingPauseButton />
) : (
<Typography color="warning">
No download mode is active
</Typography>
)}
</div>
<Box display="flex" gap={2}>
<Button
size="small"
@@ -183,7 +193,7 @@ export const ProcessingList = () => {
disabled={
isRemoving ||
!processingList?.some((item) =>
["finished", "downloaded", "error"].includes(item.status),
["finished", "error"].includes(item.status),
)
}
>
+25 -7
View File
@@ -63,6 +63,8 @@ type ApiFetcherContextType = {
pause_queue: () => Promise<void>;
resume_queue: () => Promise<void>;
get_queue_status: () => Promise<{ isPaused: boolean } | undefined>;
get_list_history: () => Promise<string[] | undefined>;
flush_history: () => Promise<unknown>;
};
};
@@ -210,17 +212,11 @@ export function APIFetcherProvider({ children }: { children: ReactNode }) {
async function remove_all() {
return await queryExpressJS(`${apiUrl}/remove-all`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
}
async function remove_finished() {
return await queryExpressJS(`${apiUrl}/remove-finished`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
}
@@ -229,10 +225,10 @@ export function APIFetcherProvider({ children }: { children: ReactNode }) {
async function auth(body?: string): Promise<AuthType | undefined> {
return await queryExpressJS<AuthType>(`${apiUrl}/auth`, {
method: "POST",
body: body,
headers: {
"Content-Type": "application/json",
},
body: body,
});
}
@@ -432,6 +428,26 @@ export function APIFetcherProvider({ children }: { children: ReactNode }) {
);
}
// Queue History
async function get_list_history() {
return await queryExpressJS<string[]>(`${apiUrl}/history/list`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
}
async function flush_history() {
return await queryExpressJS(`${apiUrl}/history/list`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
}
const value = {
apiUrl,
error: {
@@ -462,6 +478,8 @@ export function APIFetcherProvider({ children }: { children: ReactNode }) {
pause_queue,
resume_queue,
get_queue_status,
get_list_history,
flush_history,
},
};
+62
View File
@@ -0,0 +1,62 @@
import React, { ReactNode, useContext, useEffect, useState } from "react";
import { useApiFetcher } from "./ApiFetcherProvider";
import { useConfigProvider } from "./ConfigProvider";
type HistoryContextType = {
history: string[];
actions: {
addToHistory: (id: string) => void;
emptyHistory: () => void;
};
};
const HistoryContext = React.createContext<HistoryContextType>(
{} as HistoryContextType,
);
export function HistoryProvider({ children }: { children: ReactNode }) {
const [history, setHistory] = useState<string[]>([]);
const { config } = useConfigProvider();
const {
actions: { get_list_history, flush_history },
} = useApiFetcher();
const addToHistory = (id: string) => {
const formatId = id.toString();
if (history.includes(formatId)) return;
setHistory([...history, formatId]);
};
const emptyHistory = async () => {
await flush_history();
setHistory([]);
};
useEffect(() => {
if (config?.ENABLE_HISTORY !== "true") return;
async function get_list() {
const data = await get_list_history();
setHistory(data || []);
}
get_list();
}, [config, get_list_history]);
const value = {
history,
actions: {
addToHistory,
emptyHistory,
},
};
return (
<HistoryContext.Provider value={value}>{children}</HistoryContext.Provider>
);
}
export const useHistoryProvider = () => {
return useContext(HistoryContext);
};
+7 -1
View File
@@ -353,7 +353,13 @@ export type ProcessingItemType = {
title: string;
quality: QualityType;
type: ContentType;
status: "queue" | "finished" | "beet" | "processing" | "error";
status:
| "queue"
| "finished"
| "beet"
| "processing"
| "error"
| "no_download";
url: string;
loading: boolean;
error: boolean;
-1461
View File
File diff suppressed because it is too large. Load diff
+5 -3
View File
@@ -9,9 +9,9 @@ services:
- 8484:8484
- 3000:3000
volumes:
- ./docker/mnt/config:/home/app/standalone/shared
- ./docker/mnt/library:/home/app/standalone/library
- .:/home/app/standalone # only used in dev environment
- ./docker/mnt/config:/shared
- ./docker/mnt/library:/music
- .:/tidarr # only used in dev environment
restart: 'unless-stopped'
environment:
- PUID=
@@ -34,4 +34,6 @@ services:
- PUSH_OVER_URL=
- M3U_BASEPATH_FILE=
- SYNC_CRON_EXPRESSION=
- NO_DOWNLOAD=
- ENABLE_HISTORY=
+14 -14
View File
@@ -7,11 +7,11 @@ ARG TARGETPLATFORM
FROM python:3.13-alpine3.21 AS base
WORKDIR /home/app/standalone
WORKDIR /tidarr
ENV SHELL=bash \
PYTHONUNBUFFERED=1 \
HOME=/home/app/standalone/shared
HOME=/shared
# Install system dependencies (shared by dev and prod)
RUN apk update && apk upgrade && \
@@ -36,8 +36,8 @@ RUN apk add --no-cache --virtual .build-deps \
apk del .build-deps
# Create beets directory structure
RUN mkdir -p /home/app/standalone/shared/beets && \
touch /home/app/standalone/shared/beets/beets-library.blb
RUN mkdir -p /shared/beets && \
touch /shared/beets/beets-library.blb
###############################################################
## DEVELOPMENT IMAGE #
@@ -62,7 +62,7 @@ EXPOSE 8484 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8484/api/is-auth-active || exit 1
ENTRYPOINT ["sh", "/home/app/standalone/docker/entrypoint.sh"]
ENTRYPOINT ["sh", "/tidarr/docker/entrypoint.sh"]
###############################################################
## BUILD APP (production only) #
@@ -70,7 +70,7 @@ ENTRYPOINT ["sh", "/home/app/standalone/docker/entrypoint.sh"]
FROM --platform=$BUILDPLATFORM ghcr.io/linuxserver/baseimage-alpine:3.21 AS app_builder
WORKDIR /home/app/standalone
WORKDIR /tidarr
ENV SHELL=bash
ARG NODE_ENV
@@ -101,12 +101,12 @@ ARG VERSION
ENV VERSION=$VERSION
# Copy built artifacts from builder stage
COPY --from=app_builder /home/app/standalone/api/dist /home/app/standalone/api/dist
COPY --from=app_builder /home/app/standalone/api/scripts /home/app/standalone/api/scripts
COPY --from=app_builder /home/app/standalone/app/dist /home/app/standalone/app/build
COPY --from=app_builder /home/app/standalone/settings /home/app/standalone/settings
COPY --from=app_builder /home/app/standalone/.env /home/app/standalone/.env
COPY --from=app_builder /home/app/standalone/docker/entrypoint.sh /home/app/standalone/docker/entrypoint.sh
COPY --from=app_builder /tidarr/api/dist /tidarr/api/dist
COPY --from=app_builder /tidarr/api/scripts /tidarr/api/scripts
COPY --from=app_builder /tidarr/app/dist /tidarr/app/build
COPY --from=app_builder /tidarr/settings /tidarr/settings
COPY --from=app_builder /tidarr/.env /tidarr/.env
COPY --from=app_builder /tidarr/docker/entrypoint.sh /tidarr/docker/entrypoint.sh
# Copy workspace configuration and API package files
COPY package.json yarn.lock* ./
@@ -114,7 +114,7 @@ COPY api/package.json ./api/package.json
# Install production dependencies and clean up aggressively
RUN --mount=type=cache,target=/usr/local/share/.cache/yarn/v6,sharing=locked \
mkdir -p /home/app/standalone/shared/.processing && \
mkdir -p /shared/.processing && \
yarn install --frozen-lockfile --production && \
npm uninstall -g npm && \
rm -rf /root/.npm /tmp/* /var/tmp/* && \
@@ -126,4 +126,4 @@ EXPOSE 8484
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8484/api/is-auth-active || exit 1
ENTRYPOINT ["sh", "/home/app/standalone/docker/entrypoint.sh"]
ENTRYPOINT ["sh", "/tidarr/docker/entrypoint.sh"]
+12 -12
View File
@@ -42,31 +42,31 @@ if [ -n "$PUID" ] && [ -n "$PGID" ]; then
echo "🔑 [TIDARR] Using UMASK=$EFFECTIVE_UMASK"
# Create required directories if they don't exist
mkdir -p /home/app/standalone/shared/.tiddl 2>/dev/null || true
mkdir -p /home/app/standalone/shared/beets 2>/dev/null || true
mkdir -p /shared/.tiddl 2>/dev/null || true
mkdir -p /shared/beets 2>/dev/null || true
# Change ownership only of specific Tidarr-related files/directories
# Avoid traversing cache directories (.cache, .yarn, .pki, node_modules)
chown $PUID:$PGID /home/app/standalone/shared 2>/dev/null || true
chown -R $PUID:$PGID /home/app/standalone/shared/.tiddl 2>/dev/null || true
chown -R $PUID:$PGID /home/app/standalone/shared/beets 2>/dev/null || true
chown $PUID:$PGID /home/app/standalone/shared/*.json 2>/dev/null || true
chown $PUID:$PGID /home/app/standalone/shared/*.css 2>/dev/null || true
chown $PUID:$PGID /home/app/standalone/shared/*.yml 2>/dev/null || true
chown $PUID:$PGID /shared 2>/dev/null || true
chown -R $PUID:$PGID /shared/.tiddl 2>/dev/null || true
chown -R $PUID:$PGID /shared/beets 2>/dev/null || true
chown $PUID:$PGID /shared/*.json 2>/dev/null || true
chown $PUID:$PGID /shared/*.css 2>/dev/null || true
chown $PUID:$PGID /shared/*.yml 2>/dev/null || true
# In production, allow the user to write custom.css in the app/build directory
if [ "$ENVIRONMENT" != "development" ]; then
chown -R $PUID:$PGID /home/app/standalone/app/build 2>/dev/null || true
chown -R $PUID:$PGID /tidarr/app/build 2>/dev/null || true
fi
# Run as specified UID/GID using su-exec
# Set HOME to /home/app/standalone/shared for tiddl config access
# Set HOME to /shared for tiddl config access
# Export UMASK as environment variable so Node.js can access it for setPermissions()
# Set umask for any processes that respect it (tiddl/Python ignores shell umask)
if [ "$ENVIRONMENT" = "development" ]; then
exec su-exec $PUID:$PGID sh -c "export HOME=/home/app/standalone/shared && export UMASK=$EFFECTIVE_UMASK && umask $EFFECTIVE_UMASK && exec sh -c 'yarn install && yarn dev'"
exec su-exec $PUID:$PGID sh -c "export HOME=/shared && export UMASK=$EFFECTIVE_UMASK && umask $EFFECTIVE_UMASK && exec sh -c 'yarn install && yarn dev'"
else
exec su-exec $PUID:$PGID sh -c "export HOME=/home/app/standalone/shared && export UMASK=$EFFECTIVE_UMASK && umask $EFFECTIVE_UMASK && exec yarn --cwd ./api prod"
exec su-exec $PUID:$PGID sh -c "export HOME=/shared && export UMASK=$EFFECTIVE_UMASK && umask $EFFECTIVE_UMASK && exec yarn --cwd ./api prod"
fi
else
# Run as root (default)
+48 -1
View File
@@ -7,6 +7,7 @@ This documentation describes how to use the Tidarr REST API with curl to automat
- [Basic Configuration](#basic-configuration)
- [Authentication](#authentication)
- [Download Endpoints](#download-endpoints)
- [History Endpoints](#history-endpoints)
- [Configuration Endpoints](#configuration-endpoints)
- [Synchronization Endpoints](#synchronization-endpoints)
- [Custom CSS Endpoints](#custom-css-endpoints)
@@ -315,6 +316,52 @@ curl http://localhost:8484/api/queue/status \
---
## History Endpoints
The history feature tracks downloaded items. It can be enabled by setting `ENABLE_HISTORY=true` in your Docker configuration.
### Get download history
Returns the list of all downloaded items.
```bash
curl http://localhost:8484/api/history/list \
-H "Authorization: Bearer $TIDARR_TOKEN"
```
**Response:**
```json
[
{
"id": "251082404",
"type": "album",
"url": "https://listen.tidal.com/album/251082404",
"title": "Album Name",
"downloadedAt": 1234567890
}
]
```
**Note:** History is only available when `ENABLE_HISTORY=true` is set in your environment variables.
### Clear download history
Removes all items from the download history.
```bash
curl -X DELETE http://localhost:8484/api/history/list \
-H "Authorization: Bearer $TIDARR_TOKEN"
```
**Response:** Status `204 No Content`
**Behavior:**
- Deletes the entire download history
- Does not affect the current download queue
- Only clears the history tracking, does not delete downloaded files
---
## Configuration Endpoints
### Get Tidarr configuration
@@ -424,7 +471,7 @@ curl -X POST http://localhost:8484/api/sync/trigger \
**Response:** Status `202 Accepted`
This endpoint will immediately queue all items from the watch list for download. Items already in the queue with status "processing" will be skipped. Items with status "finished" or "downloaded" will be removed from the queue and re-added.
This endpoint will immediately queue all items from the watch list for download. Items already in the queue with status "processing" will be skipped. Items with status "finished" will be removed from the queue and re-added.
**Note:** The synchronization cron is configured via the `SYNC_CRON_EXPRESSION` environment variable (default: `0 3 * * *` = every day at 3 AM).
+2 -3
View File
@@ -244,9 +244,8 @@ services:
- PUID=1000
- PGID=1000
volumes:
- ./shared:/app/shared
- ./library:/library
- ./incomplete:/incomplete
- ./shared:/shared
- ./library:/music
networks:
- proxy_network
+3
View File
@@ -33,6 +33,9 @@ ADMIN_PASSWORD=
LOCK_QUALITY=
VERSION=0.0.0-testing
# Active download history
ENABLE_HISTORY=true
# User/Group IDs (use host user for permissions)
PUID=1000
PGID=1000
+2
View File
@@ -0,0 +1,2 @@
# Enable Tidarr authentication
NO_DOWNLOAD=true
+2
View File
@@ -83,6 +83,8 @@ test("Tidarr config : Should see configuration dialog", async ({ page }) => {
["LOCK_QUALITY", ""],
["ENABLE_TIDAL_PROXY", "true"],
["SYNC_CRON_EXPRESSION", "0 3 * * *"],
["NO_DOWNLOAD", ""],
["ENABLE_HISTORY", "true"],
];
const tableAPIRows = await page
.locator("#alert-dialog-description div")
+2 -4
View File
@@ -118,7 +118,7 @@ test("Edit Config: Should display error dialog when config has errors", async ({
await mockConfigAPI(page, {
configErrors: [
"Config file error: Cannot redefine existing key 'templates'.",
"Config path: /home/app/standalone/shared/.tiddl/config.toml",
"Config path: /shared/.tiddl/config.toml",
],
});
@@ -145,9 +145,7 @@ test("Edit Config: Should display error dialog when config has errors", async ({
),
).toBeVisible();
await expect(
page.getByText(
"Config path: /home/app/standalone/shared/.tiddl/config.toml",
),
page.getByText("Config path: /shared/.tiddl/config.toml"),
).toBeVisible();
// Close the dialog
+62
View File
@@ -0,0 +1,62 @@
import { expect } from "@playwright/test";
import { test } from "../test-isolation";
import { mockConfigAPI } from "./utils/mock";
import { runSearch } from "./utils/search";
test("History: Should see and clear all history when flush is called", async ({
page,
}) => {
// Enable history feature
await mockConfigAPI(page, { ENABLE_HISTORY: "true" });
let historyDeleteCalled = false;
const historyAfterDelete: string[] = [];
// Mock initial history list with items
await page.route("**/history/list", async (route) => {
if (route.request().method() === "DELETE") {
historyDeleteCalled = true;
await route.fulfill({ status: 204 });
return;
}
// GET request - return history based on whether delete was called
const mockHistory = historyDeleteCalled
? historyAfterDelete
: ["77610756", "77610844"];
await route.fulfill({ status: 200, json: mockHistory });
});
await runSearch("Nirvana", page);
await expect(page.locator(".MuiButton-colorSuccess")).toHaveCount(7);
// Navigate to settings page (where flush history button might be)
await page.getByRole("button", { name: "Tidarr settings" }).click();
// Wait for settings to load
await page.waitForTimeout(500);
await page.getByRole("tab", { name: "Tidal configuration" }).click();
// Look for a button or action to clear history
// Note: This assumes there's a UI element for clearing history in settings
// Adjust the selector based on your actual implementation
const clearHistoryButton = page.getByRole("button", {
name: /Empty history/i,
});
// If the button exists, click it
await clearHistoryButton.isVisible();
await clearHistoryButton.click();
// Wait for the API call
await page.waitForTimeout(500);
// Verify the DELETE API was called
expect(historyDeleteCalled).toBe(true);
await expect(page.locator(".MuiButton-colorSuccess")).toHaveCount(0);
});
+145
View File
@@ -0,0 +1,145 @@
import { expect } from "@playwright/test";
import { test } from "../test-isolation";
import { runSearch } from "./utils/search";
test.use({ envFile: ".env.e2e.nodownload" });
test("NO_DOWNLOAD: Should set items to 'no_download' status when NO_DOWNLOAD is enabled", async ({
page,
}) => {
// Add an item to the queue
await runSearch("Nirvana", page);
await page.getByRole("tab", { name: "Albums" }).first().click();
await page
.locator("div:nth-child(2) > .MuiPaper-root > div:nth-child(2)")
.getByTestId("btn-dl")
.click();
// Open processing list
await expect(page.locator("button.MuiFab-circular")).toBeVisible();
await page.locator("button.MuiFab-circular").click();
// Verify item is in the list with "no_download" status
await expect(page.getByLabel("Processing table")).toContainText("In Utero");
await expect(page.getByLabel("Processing table")).toContainText(
"no_download",
);
});
test("NO_DOWNLOAD: Should not display pause button when NO_DOWNLOAD is enabled", async ({
page,
}) => {
// Add an item to the queue
await runSearch("Nirvana", page);
await page.getByRole("tab", { name: "Albums" }).first().click();
await page.getByRole("button", { name: "Get album" }).nth(2).click();
// Open processing list
await expect(page.locator("button.MuiFab-circular")).toBeVisible();
await page.locator("button.MuiFab-circular").click();
// Wait for dialog to open
await page.waitForTimeout(500);
// Verify pause button is NOT visible
const pauseButton = page.getByRole("button", { name: "Pause" });
await expect(pauseButton).not.toBeVisible();
});
test("NO_DOWNLOAD: Should not display terminal button for no_download items", async ({
page,
}) => {
// Add an item to the queue
await runSearch("Nirvana", page);
await page.getByRole("tab", { name: "Albums" }).first().click();
await page
.locator("div:nth-child(2) > .MuiPaper-root > div:nth-child(2)")
.getByTestId("btn-dl")
.click();
// Open processing list
await expect(page.locator("button.MuiFab-circular")).toBeVisible();
await page.locator("button.MuiFab-circular").click();
// Wait for the table to be visible
await page.waitForSelector('[aria-label="Processing table"]', {
state: "visible",
timeout: 5000,
});
// Verify terminal button is NOT visible
const terminalButton = page.getByTestId("btn-console");
await expect(terminalButton).not.toBeVisible();
});
test("NO_DOWNLOAD: Should display Block icon for no_download items in download button", async ({
page,
}) => {
// Add an item to the queue
await runSearch("Nirvana", page);
await page.getByRole("tab", { name: "Albums" }).first().click();
// Find the download button and click it
const downloadButton = page
.locator("div:nth-child(2) > .MuiPaper-root > div:nth-child(2)")
.getByTestId("btn-dl");
await downloadButton.click();
// Wait a bit for the status to update
await page.waitForTimeout(500);
// Verify the download button is disabled
await expect(downloadButton).toBeDisabled();
});
test("NO_DOWNLOAD: Should display primary color FAB when NO_DOWNLOAD is enabled", async ({
page,
}) => {
// Add an item to the queue
await runSearch("Nirvana", page);
await page.getByRole("tab", { name: "Albums" }).first().click();
await page.getByRole("button", { name: "Get album" }).nth(2).click();
// Wait for FAB to appear
await expect(page.locator("button.MuiFab-circular")).toBeVisible();
// Check if the FAB has primary color
const fab = page.locator("button.MuiFab-circular");
await expect(fab).toHaveClass(/MuiFab-primary/);
});
test("NO_DOWNLOAD: Should allow clearing items even in no_download mode", async ({
page,
}) => {
// Add an item to the queue
await runSearch("Nirvana", page);
await page.getByRole("tab", { name: "Albums" }).first().click();
await page.getByRole("button", { name: "Get album" }).nth(2).click();
// Open processing list
await expect(page.locator("button.MuiFab-circular")).toBeVisible();
await page.locator("button.MuiFab-circular").click();
// Verify item is in the list
await expect(page.getByLabel("Processing table")).toContainText(
"MTV Unplugged In New York",
);
// Setup confirmation dialog handler
page.once("dialog", (dialog) => {
expect(dialog.message()).toContain(
"Are you sure you want to clear all items from the queue?",
);
dialog.accept();
});
// Click "Clear all" button
await page.waitForTimeout(500);
await page.getByRole("button", { name: "Clear all" }).click();
// Verify the API was called
await page.waitForTimeout(500);
await expect(page.locator("button.MuiFab-circular")).not.toBeVisible();
});
-46
View File
@@ -1,46 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@playwright/test@^1.56.1":
version "1.56.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.56.1.tgz#6e3bf3d0c90c5cf94bf64bdb56fd15a805c8bd3f"
integrity sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==
dependencies:
playwright "1.56.1"
"@types/node@^24.10.0":
version "24.10.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.0.tgz#6b79086b0dfc54e775a34ba8114dcc4e0221f31f"
integrity sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==
dependencies:
undici-types "~7.16.0"
dotenv@^17.2.3:
version "17.2.3"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.2.3.tgz#ad995d6997f639b11065f419a22fabf567cdb9a2"
integrity sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==
fsevents@2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
playwright-core@1.56.1:
version "1.56.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.56.1.tgz#24a66481e5cd33a045632230aa2c4f0cb6b1db3d"
integrity sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==
playwright@1.56.1, playwright@^1.54.2:
version "1.56.1"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.56.1.tgz#62e3b99ddebed0d475e5936a152c88e68be55fbf"
integrity sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==
dependencies:
playwright-core "1.56.1"
optionalDependencies:
fsevents "2.3.2"
undici-types@~7.16.0:
version "7.16.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
+1 -1
View File
@@ -24,7 +24,7 @@ import:
quiet_fallback: asis
timid: no
duplicate_action: asis
log: /home/app/standalone/shared/beets/beet.log
log: /shared/beets/beet.log
languages: ['en']
group_albums: no
+5 -2
View File
@@ -53,13 +53,16 @@ skip_existing = true
threads_count = 4
# base download directory, by default it is set to your home directory / Music / tiddl
download_path = "/home/app/standalone/shared/.processing"
download_path = "/shared/.processing"
# if you moved the downloaded files to other directory,
# then you should specify the destination directory there.
# otherwise `tiddl` will not detect them and `skip_existing` will not skip
# already downloaded files. by default scan path is set to your download path.
scan_path = "/home/app/standalone/library"
#
# /!\ Should match your mounted library docker volume.
#
scan_path = "/music"
# this option is used to determine if you want to include downloading singles from an artist.
# "none" download only full albums
+389 -482
View File
File diff suppressed because it is too large. Load diff