diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 588d8ec..dd49f58 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,52 +1,26 @@
# ------------------------------------------------------------
# Base Image
# ------------------------------------------------------------
-FROM debian:unstable-slim
-
-ENV DEBIAN_FRONTEND=noninteractive
-ENV LANG=C.UTF-8
-ENV LC_ALL=C.UTF-8
+FROM mcr.microsoft.com/devcontainers/base:debian
# ------------------------------------------------------------
# System Dependencies
# ------------------------------------------------------------
-RUN apt-get update && apt-get upgrade -y && \
+RUN apt update && apt upgrade -y && \
apt-get install -y --no-install-recommends \
- curl \
- ca-certificates \
git \
- build-essential \
- sudo \
+ git-lfs \
fish \
- unzip \
- xz-utils \
- libatomic1 \
- libc6 \
- wget \
nodejs \
- npm && \
- rm -rf /var/lib/apt/lists/*
-
-# ------------------------------------------------------------
-# Create Non-Root User
-# ------------------------------------------------------------
-ARG USERNAME=devuser
-ARG UID=1000
-ARG GID=1000
-
-RUN groupadd --gid ${GID} ${USERNAME} && \
- useradd --uid ${UID} --gid ${GID} -m -s /usr/bin/fish ${USERNAME} && \
- echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
-
-USER ${USERNAME}
-WORKDIR /home/${USERNAME}
+ npm \
+ curl
# ------------------------------------------------------------
# Install Bun (Non-Root)
# ------------------------------------------------------------
-ENV BUN_INSTALL=/home/${USERNAME}/.bun
-ENV PATH="${BUN_INSTALL}/bin:${PATH}"
-
+ENV BUN_INSTALL="$HOME/.bun"
+ENV PATH="$BUN_INSTALL/bin:$PATH"
+
RUN curl -fsSL https://bun.sh/install | bash
# ------------------------------------------------------------
@@ -58,7 +32,7 @@ RUN curl -fsSL https://opencode.ai/install -o opencode-install && \
rm opencode-install
# Add OpenCode to PATH permanently
-ENV PATH="/home/${USERNAME}/.opencode/bin:${PATH}"
+ENV PATH="$HOME/.opencode/bin:$PATH"
# ------------------------------------------------------------
# Ensure fish is Default Shell
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 3aee656..bd87db0 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -1,22 +1,14 @@
{
- "name": "debian-npm-fish-devcontainer",
+ "name": "Monochrome Dev Container",
"build": {
- "dockerfile": "Dockerfile"
+ "context": "..",
+ "dockerfile": "./Dockerfile"
},
-
- "remoteUser": "devuser",
-
- "features": {},
-
+ "postCreateCommand": "git config --local core.editor \"code --wait\" && git config --local commit.gpgsign false && npm install && bun install",
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}
},
-
- "postCreateCommand": "npm install",
-
- "remoteEnv": {
- "SHELL": "/usr/bin/fish"
- }
+ "mounts": ["source=${env:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,consistency=cached"]
}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..6938754
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,23 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "npm",
+ "script": "build",
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ },
+ "problemMatcher": [],
+ "label": "npm: build",
+ "detail": "vite build"
+ },
+ {
+ "type": "npm",
+ "script": "dev",
+ "problemMatcher": [],
+ "label": "npm: dev",
+ "detail": "vite"
+ }
+ ]
+}
diff --git a/bun.lock b/bun.lock
index 9b8e910..7a7217b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -16,6 +16,7 @@
"cookie-session": "^2.1.1",
"dashjs": "^5.1.1",
"fuse.js": "^7.1.0",
+ "hls.js": "^1.6.15",
"jose": "^6.2.0",
"npm": "^11.11.0",
"pocketbase": "^0.26.8",
@@ -27,6 +28,7 @@
"@types/node": "^25.3.5",
"eslint": "^9.39.3",
"eslint-config-prettier": "^10.1.8",
+ "formidable": "^3.5.4",
"globals": "^17.4.0",
"htmlhint": "^1.9.2",
"miniflare": "^4.20260301.1",
@@ -42,6 +44,7 @@
},
},
"overrides": {
+ "serialize-javascript": "^7.0.3",
"source-map": "^0.7.4",
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@^1.4.14",
},
@@ -422,12 +425,16 @@
"@neutralinojs/neu": ["@neutralinojs/neu@11.7.0", "", { "dependencies": { "@electron/asar": "^3.0.3", "chalk": "^4.1.0", "chokidar": "^4.0.3", "commander": "^7.2.0", "configstore": "^5.0.1", "edit-json-file": "^1.6.2", "follow-redirects": "^1.13.1", "fs-extra": "^9.0.1", "pe-library": "^1.0.1", "png2icons": "^2.0.1", "postject": "1.0.0-alpha.6", "recursive-readdir": "^2.2.2", "resedit": "^2.0.2", "spawn-command": "^1.0.0", "tcp-port-used": "^1.0.2", "uuid": "^8.3.2", "websocket": "^1.0.35", "zip-lib": "^1.0.4" }, "bin": { "neu": "bin/neu.js" } }, "sha512-fUqvR70a+BpKI9mrD92ldZkVC24Rs8XL/9m7zmOCLgCRys3yuWy7vEsxpHzKMzqTiQJkTYIsLmcR8VMzNIjuSw=="],
+ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
+
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
+ "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
+
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
@@ -552,6 +559,8 @@
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
+ "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
+
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
@@ -678,6 +687,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+ "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="],
+
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
"dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
@@ -778,7 +789,7 @@
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
- "flatted": ["flatted@3.3.4", "", {}, "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA=="],
+ "flatted": ["flatted@3.4.0", "", {}, "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
@@ -786,6 +797,8 @@
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
+ "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="],
+
"fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
@@ -848,6 +861,8 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+ "hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
+
"hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="],
"html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="],
@@ -1148,8 +1163,6 @@
"r-json": ["r-json@1.3.1", "", { "dependencies": { "w-json": "1.3.10" } }, "sha512-5nhRFfjVMQdrwKUfUlRpDUCocdKtjSnYZ1R/86mpZDV3MfsZ3dYYNjSGuMX+mPBvFvQBhdzxSqxkuLPLv4uFGg=="],
- "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
-
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"recursive-readdir": ["recursive-readdir@2.2.3", "", { "dependencies": { "minimatch": "^3.0.5" } }, "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA=="],
@@ -1196,7 +1209,7 @@
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
- "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
+ "serialize-javascript": ["serialize-javascript@7.0.4", "", {}, "sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
diff --git a/functions/album/[id].js b/functions/album/[id].js
index 95f6fbf..8a5e2a9 100644
--- a/functions/album/[id].js
+++ b/functions/album/[id].js
@@ -84,7 +84,7 @@ class ServerAPI {
getCoverUrl(id, size = '1280') {
if (!id) return '';
- const formattedId = id.replace(/-/g, '/');
+ const formattedId = String(id).replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
}
diff --git a/functions/playlist/[id].js b/functions/playlist/[id].js
index ae41d1a..c20a6ac 100644
--- a/functions/playlist/[id].js
+++ b/functions/playlist/[id].js
@@ -85,7 +85,7 @@ class ServerAPI {
getCoverUrl(id, size = '1080') {
if (!id) return '';
- const formattedId = id.replace(/-/g, '/');
+ const formattedId = String(id).replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
}
diff --git a/functions/track/[id].js b/functions/track/[id].js
index fad3f21..e96fa94 100644
--- a/functions/track/[id].js
+++ b/functions/track/[id].js
@@ -98,7 +98,7 @@ class ServerAPI {
getCoverUrl(id, size = '1280') {
if (!id) return '';
- const formattedId = id.replace(/-/g, '/');
+ const formattedId = String(id).replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
diff --git a/index.html b/index.html
index 5b4b4b7..d8e8e0c 100644
--- a/index.html
+++ b/index.html
@@ -36,32 +36,37 @@
-
+
+
+
+
-
-
Compact Artists
-
Show artist cards in a compact, horizontal layout
+
+
+ Zipped Bulk Downloads
+ Download multiple tracks as a single ZIP file (requires browser
+ support)
+
+
-
-
-
-
-
Compact Albums
-
Show album cards in a compact, horizontal layout
+
+
+ Download Lyrics
+ Include .lrc files when downloading tracks/albums
+
+
-
-
-
-
-
-
-
-
-
-
-
Zipped Bulk Downloads
-
Download multiple tracks as a single ZIP file (requires browser support)
+
+
+ Romaji Lyrics
+ Convert Japanese lyrics to Romaji (Latin characters)
+
+
-
-
-
-
-
Download Lyrics
-
Include .lrc files when downloading tracks/albums
+
+
+ Download Quality
+ Quality for track downloads
+
+
-
-
-
-
-
Romaji Lyrics
-
Convert Japanese lyrics to Romaji (Latin characters)
+
+
+ Lossless Container
+ Container format for lossless downloads
+
+
-
-
-
-
- Filename Template
- Customize download filenames. Available: {trackNumber}, {artist}, {title},
- {album}
-
-
-
-
-
- ZIP Folder Template
- Customize album folder names. Available: {albumTitle}, {albumArtist},
- {year}
-
-
-
-
-
- Generate M3U
- Include M3U playlist files in downloads
-
-
-
-
-
- Generate M3U8
- Include extended M3U8 playlist files in downloads
-
-
-
-
-
- Generate CUE
- Include CUE sheets for gapless playback in downloads
-
-
-
-
-
- Generate NFO
- Include NFO files for media center compatibility
-
-
-
-
-
- Generate JSON
- Include JSON files with rich metadata
-
-
-
-
-
- Relative Paths
- Use relative paths in playlist files
-
-
-
-
-
- Separate Discs in ZIP
- Put tracks in Disc folders when a release has multiple discs
-
-
-
-
-
-
-
-
-
-
-
- Keyboard Shortcuts
- View and customize keyboard shortcuts
-
-
-
-
-
- Cache
- Stores API responses to reduce requests
-
-
-
-
-
- Auto-Update App
- Automatically reload when a new version is available
-
-
-
-
-
- Desktop Update
- Check for updates to the desktop application
-
-
-
-
-
- Analytics
- Send anonymous usage data to help improve the app
-
-
-
-
-
- Reset Local Data
- Clear all local storage and cached data (does not affect cloud sync)
-
-
-
-
-
- Clear Cloud Data
- Delete all your data from the cloud (cannot be undone)
-
-
-
-
-
- Backup & Restore
- Export or import your library and history as JSON
-
-
-
-
-
-
-
-
-
- Export All Settings
- Export all app settings as JSON
-
-
-
-
+
+
+ Cover Art Size
+ Size for downloaded/embedded cover art
+
+
+
+
+ Filename Template
+ Customize download filenames. Available: {trackNumber}, {artist}, {title},
+ {album}
+
+
+
+
+
+ ZIP Folder Template
+ Customize album folder names. Available: {albumTitle}, {albumArtist},
+ {year}
+
+
-
-
- ADVANCED: Custom Database/Auth
- Configure custom PocketBase and Firebase instances
-
-
-
-
-
+
+
+
- API Instances
- Manage and prioritize API instances.
+ Generate M3U
+ Include M3U playlist files in downloads
-
+
+
+
+
+ Generate M3U8
+ Include extended M3U8 playlist files in downloads
+
+
+
+
+
+ Generate CUE
+ Include CUE sheets for gapless playback in downloads
+
+
+
+
+
+ Generate NFO
+ Include NFO files for media center compatibility
+
+
+
+
+
+ Generate JSON
+ Include JSON files with rich metadata
+
+
-
-
-
-
Blocked Content
-
Manage artists, albums, and tracks you've blocked from recommendations
+
+
+
+ Relative Paths
+ Use relative paths in playlist files
+
+
-
-
-
+
+
+
+
+
+
+
+
+ ADVANCED: Custom Database/Auth
+ Configure custom PocketBase and Firebase instances
+
+
+
+
+
+
+
+
+
+ API Instances
+ Manage and prioritize API instances.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keyboard Shortcuts
+ View and customize keyboard shortcuts
+
+
+
+
+
+ Cache
+ Stores API responses to reduce requests
+
+
+
+
+
+ Auto-Update App
+ Automatically reload when a new version is available
+
+
+
+
+
+ Desktop Update
+ Check for updates to the desktop application
+
+
+
+
+
+ Analytics
+ Send anonymous usage data to help improve the app
+
+
+
+
+
+
+
+
+ Reset Local Data
+ Clear all local storage and cached data (does not affect cloud sync)
+
+
+
+
+
+ Clear Cloud Data
+ Delete all your data from the cloud (cannot be undone)
+
+
-
-
-
- Blocked Artists
-
-
+
+
+
+
+ Backup & Restore
+ Export or import your library and history as JSON
+
+
+
+
+
+
-
-
- Blocked Albums
-
-
+
+
+ Export All Settings
+ Export all app settings as JSON
+
+
+
+
+
+
-
-
- No blocked content
+
+
+
+
+
+ Blocked Content
+ Manage artists, albums, and tracks you've blocked from
+ recommendations
+
+
+
+
+
diff --git a/js/accounts/auth.js b/js/accounts/auth.js
index 27fb1bd..8a422f6 100644
--- a/js/accounts/auth.js
+++ b/js/accounts/auth.js
@@ -31,7 +31,7 @@ export class AuthManager {
this.user = await auth.get();
this.updateUI(this.user);
this.authListeners.forEach((listener) => listener(this.user));
- } catch (error) {
+ } catch {
this.user = null;
this.updateUI(null);
}
diff --git a/js/accounts/pocketbase.js b/js/accounts/pocketbase.js
index 55d2008..67a2f08 100644
--- a/js/accounts/pocketbase.js
+++ b/js/accounts/pocketbase.js
@@ -200,6 +200,19 @@ const syncManager = {
};
}
+ if (type === 'video') {
+ return {
+ ...base,
+ type: 'video',
+ title: item.title || null,
+ duration: item.duration || null,
+ image: item.image || item.cover || null,
+ artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0] : null) || null,
+ artists: item.artists?.map((a) => ({ id: a.id, name: a.name || null })) || [],
+ album: item.album || { title: 'Video', cover: item.image || item.cover },
+ };
+ }
+
if (type === 'album') {
return {
...base,
@@ -280,7 +293,7 @@ const syncManager = {
id: playlist.id,
name: playlist.name,
cover: playlist.cover || null,
- tracks: playlist.tracks ? playlist.tracks.map((t) => this._minifyItem('track', t)) : [],
+ tracks: playlist.tracks ? playlist.tracks.map((t) => this._minifyItem(t.type || 'track', t)) : [],
createdAt: playlist.createdAt || Date.now(),
updatedAt: playlist.updatedAt || Date.now(),
numberOfTracks: playlist.tracks ? playlist.tracks.length : 0,
@@ -572,7 +585,9 @@ const syncManager = {
id: playlist.id,
name: playlist.name,
cover: playlist.cover || null,
- tracks: playlist.tracks ? playlist.tracks.map((t) => this._minifyItem('track', t)) : [],
+ tracks: playlist.tracks
+ ? playlist.tracks.map((t) => this._minifyItem(t.type || 'track', t))
+ : [],
createdAt: playlist.createdAt || Date.now(),
updatedAt: playlist.updatedAt || Date.now(),
numberOfTracks: playlist.tracks ? playlist.tracks.length : 0,
diff --git a/js/api.js b/js/api.js
index 0ea7b1a..cc8e0e3 100644
--- a/js/api.js
+++ b/js/api.js
@@ -10,6 +10,7 @@ import { trackDateSettings, losslessContainerSettings } from './storage.js';
import { APICache } from './cache.js';
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
import { DashDownloader } from './dash-downloader.js';
+import { HlsDownloader } from './hls-downloader.js';
import { encodeToMp3, MP3EncodingError } from './mp3-encoder.js';
import { ffmpeg, loadFfmpeg } from './ffmpeg.js';
@@ -59,6 +60,18 @@ export class LosslessAPI {
}
}
+ if (options.allowedDomains) {
+ instances = instances.filter((instance) => {
+ const url = typeof instance === 'string' ? instance : instance.url;
+ return options.allowedDomains.some((domain) => url.includes(domain));
+ });
+ if (instances.length === 0) {
+ throw new Error(
+ `No API instances configured for type: ${type} matching allowedDomains: ${options.allowedDomains.join(', ')}`
+ );
+ }
+ }
+
const maxTotalAttempts = instances.length * 2; // Allow some retries across instances
let lastError = null;
let instanceIndex = Math.floor(Math.random() * instances.length);
@@ -156,8 +169,15 @@ export class LosslessAPI {
prepareTrack(track) {
let normalized = track;
+ if (track.type && typeof track.type === 'string') {
+ const lowType = track.type.toLowerCase();
+ if (lowType === 'video' || lowType === 'track') {
+ normalized = { ...track, type: lowType };
+ }
+ }
+
if (!track.artist && Array.isArray(track.artists) && track.artists.length > 0) {
- normalized = { ...track, artist: track.artists[0] };
+ normalized = { ...normalized, artist: track.artists[0] };
}
const derivedQuality = deriveTrackQuality(normalized);
@@ -181,6 +201,16 @@ export class LosslessAPI {
return playlist;
}
+ prepareVideo(video) {
+ let normalized = { ...video, type: 'video' };
+
+ if (!video.artist && Array.isArray(video.artists) && video.artists.length > 0) {
+ normalized.artist = video.artists[0];
+ }
+
+ return normalized;
+ }
+
prepareArtist(artist) {
if (!artist.type && Array.isArray(artist.artistTypes) && artist.artistTypes.length > 0) {
return { ...artist, type: artist.artistTypes[0] };
@@ -264,8 +294,22 @@ export class LosslessAPI {
}
extractStreamUrlFromManifest(manifest) {
+ if (!manifest) return null;
+
try {
- const decoded = atob(manifest);
+ let decoded;
+ if (typeof manifest === 'string') {
+ try {
+ decoded = atob(manifest);
+ } catch {
+ decoded = manifest;
+ }
+ } else if (typeof manifest === 'object') {
+ if (manifest.urls?.[0]) return manifest.urls[0];
+ return null;
+ } else {
+ return null;
+ }
// Check if it's a DASH manifest (XML)
if (decoded.includes('
this.prepareVideo(v)),
+ };
+
+ await this.cache.set('search_videos', query, result);
+ return result;
+ } catch (error) {
+ if (error.name === 'AbortError') throw error;
+ console.error('Video search failed:', error);
+ return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
+ }
+ }
+
+ async getVideo(id) {
+ const cached = await this.cache.get('video', id);
+ if (cached) return cached;
+
+ const response = await this.fetchWithRetry(`/video/?id=${id}`, {
+ type: 'streaming',
+ allowedDomains: ['api.monochrome.tf', 'arran.monochrome.tf'],
+ });
+ const jsonResponse = await response.json();
+
+ const data = jsonResponse.data || jsonResponse;
+
+ const result = {
+ track: data,
+ info: data,
+ originalTrackUrl: data.OriginalTrackUrl || null,
+ };
+
+ await this.cache.set('video', id, result);
+ return result;
+ }
+
async getAlbum(id) {
const cached = await this.cache.get('album', id);
if (cached) return cached;
@@ -769,9 +860,11 @@ export class LosslessAPI {
const albumMap = new Map();
const trackMap = new Map();
+ const videoMap = new Map();
const isTrack = (v) => v?.id && v.duration && v.album;
const isAlbum = (v) => v?.id && 'numberOfTracks' in v;
+ const isVideo = (v) => v?.id && v.type === 'VIDEO';
const scan = (value, visited = new Set()) => {
if (!value || typeof value !== 'object' || visited.has(value)) return;
@@ -785,6 +878,7 @@ export class LosslessAPI {
const item = value.item || value;
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item));
+ if (isVideo(item)) videoMap.set(item.id, this.prepareVideo(item));
Object.values(value).forEach((nested) => scan(nested, visited));
};
@@ -792,25 +886,23 @@ export class LosslessAPI {
entries.forEach((entry) => scan(entry));
if (!options.lightweight) {
- // Attempt to find more albums/EPs via search since the direct feed might be limited
try {
- const searchResults = await this.searchAlbums(artist.name);
- if (searchResults && searchResults.items) {
+ const videoSearch = await this.searchVideos(artist.name);
+ if (videoSearch && videoSearch.items) {
const numericArtistId = Number(artistId);
-
- for (const item of searchResults.items) {
+ for (const item of videoSearch.items) {
const itemArtistId = item.artist?.id;
const matchesArtist =
itemArtistId === numericArtistId ||
(Array.isArray(item.artists) && item.artists.some((a) => a.id === numericArtistId));
- if (matchesArtist && !albumMap.has(item.id)) {
- albumMap.set(item.id, item);
+ if (matchesArtist && !videoMap.has(item.id)) {
+ videoMap.set(item.id, item);
}
}
}
} catch (e) {
- console.warn('Failed to fetch additional albums via search:', e);
+ console.warn('Failed to fetch additional videos via search:', e);
}
}
@@ -826,10 +918,14 @@ export class LosslessAPI {
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
.slice(0, 15);
+ const videos = Array.from(videoMap.values()).sort(
+ (a, b) => new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
+ );
+
// Enrich tracks with album release dates
const tracks = options.lightweight ? topTracks : await this.enrichTracksWithAlbumDates(topTracks);
- const result = { ...artist, albums, eps, tracks };
+ const result = { ...artist, albums, eps, tracks, videos };
await this.cache.set('artist', cacheKey, result);
return result;
@@ -1108,25 +1204,97 @@ export class LosslessAPI {
return streamUrl;
}
+ async getVideoStreamUrl(id) {
+ const cacheKey = `video_stream_${id}`;
+
+ if (this.streamCache.has(cacheKey)) {
+ return this.streamCache.get(cacheKey);
+ }
+
+ const lookup = await this.getVideo(id);
+
+ let streamUrl;
+
+ const findValue = (obj, key) => {
+ if (!obj || typeof obj !== 'object') return null;
+ if (obj[key]) return obj[key];
+ for (const v of Object.values(obj)) {
+ if (v && typeof v === 'object') {
+ const f = findValue(v, key);
+ if (f) return f;
+ }
+ }
+ return null;
+ };
+
+ const manifest = findValue(lookup, 'manifest') || findValue(lookup, 'Manifest');
+ if (manifest) {
+ streamUrl = this.extractStreamUrlFromManifest(manifest);
+ }
+
+ if (!streamUrl) {
+ streamUrl =
+ findValue(lookup, 'OriginalTrackUrl') ||
+ findValue(lookup, 'originalTrackUrl') ||
+ findValue(lookup, 'url') ||
+ findValue(lookup, 'streamUrl') ||
+ findValue(lookup, 'manifestUrl');
+ }
+
+ if (!streamUrl) {
+ throw new Error(`Could not resolve video stream URL for ID: ${id}`);
+ }
+
+ this.streamCache.set(cacheKey, streamUrl);
+ return streamUrl;
+ }
+
async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) {
// Load ffmpeg in the background.
loadFfmpeg().catch(console.error);
const { onProgress, track } = options;
const prefetchPromises = prefetchMetadataObjects(track, this);
+ const isVideo = track?.type === 'video';
try {
// MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert
const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality;
- const lookup = await this.getTrack(id, downloadQuality);
+ let lookup;
+ if (isVideo) {
+ lookup = await this.getVideo(id);
+ } else {
+ lookup = await this.getTrack(id, downloadQuality);
+ }
+
let streamUrl;
let blob;
if (lookup.originalTrackUrl) {
streamUrl = lookup.originalTrackUrl;
} else {
- streamUrl = this.extractStreamUrlFromManifest(lookup.info.manifest);
+ const findValue = (obj, key) => {
+ if (!obj || typeof obj !== 'object') return null;
+ if (obj[key]) return obj[key];
+ for (const v of Object.values(obj)) {
+ if (v && typeof v === 'object') {
+ const f = findValue(v, key);
+ if (f) return f;
+ }
+ }
+ return null;
+ };
+
+ const manifest = isVideo
+ ? findValue(lookup, 'manifest') || findValue(lookup, 'Manifest')
+ : lookup.info?.manifest;
+
+ if (!manifest) {
+ throw new Error('Could not resolve manifest');
+ }
+
+ streamUrl = this.extractStreamUrlFromManifest(manifest);
if (!streamUrl) {
throw new Error('Could not resolve stream URL');
}
@@ -1142,6 +1310,8 @@ export class LosslessAPI {
});
} catch (dashError) {
console.error('DASH download failed:', dashError);
+ if (isVideo) throw dashError;
+
// Fallback to LOSSLESS if DASH fails, but not if we're already downloading LOSSLESS
if (downloadQuality !== 'LOSSLESS') {
console.warn('Falling back to LOSSLESS (16-bit) download.');
@@ -1149,6 +1319,17 @@ export class LosslessAPI {
}
throw dashError;
}
+ } else if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) {
+ try {
+ const downloader = new HlsDownloader();
+ blob = await downloader.downloadHlsStream(streamUrl, {
+ signal: options.signal,
+ onProgress: options.onProgress,
+ });
+ } catch (hlsError) {
+ console.error('HLS download failed:', hlsError);
+ throw hlsError;
+ }
} else {
const response = await fetch(streamUrl, {
cache: 'no-store',
@@ -1159,7 +1340,6 @@ export class LosslessAPI {
throw new Error(`Fetch failed: ${response.status}`);
}
- // ... (standard handling for Content-Length and body reader)
const contentLength = response.headers.get('Content-Length');
const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
@@ -1185,7 +1365,8 @@ export class LosslessAPI {
}
}
- blob = new Blob(chunks, { type: response.headers.get('Content-Type') || 'audio/flac' });
+ const defaultMime = isVideo ? 'video/mp4' : 'audio/flac';
+ blob = new Blob(chunks, { type: response.headers.get('Content-Type') || defaultMime });
} else {
blob = await response.blob();
if (onProgress) {
@@ -1198,91 +1379,78 @@ export class LosslessAPI {
}
}
- // Convert to MP3 320kbps if requested
- if (quality === 'MP3_320') {
- try {
- blob = await encodeToMp3(
- blob,
- (progress) => {
- console.log(progress);
- onProgress?.(progress);
- },
- options.signal
- );
- } catch (encodingError) {
- if (onProgress) {
- onProgress({
- stage: 'error',
- message: `Encoding failed: ${encodingError.message}`,
- });
+ if (!isVideo) {
+ // Convert to MP3 320kbps if requested
+ if (quality === 'MP3_320') {
+ try {
+ blob = await encodeToMp3(blob, onProgress, options.signal);
+ } catch (encodingError) {
+ if (onProgress) {
+ onProgress({
+ stage: 'error',
+ message: `Encoding failed: ${encodingError.message}`,
+ });
+ }
+ throw encodingError;
}
- throw encodingError;
}
- }
- if (quality.endsWith('LOSSLESS')) {
- try {
- switch (losslessContainerSettings.getContainer()) {
- case 'flac':
- if ((await getExtensionFromBlob(blob)) != 'flac') {
+ if (quality.endsWith('LOSSLESS')) {
+ try {
+ switch (losslessContainerSettings.getContainer()) {
+ case 'flac':
blob = await ffmpeg(
blob,
- { args: ['-c:a', 'copy'] },
+ { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
'output.flac',
'audio/flac',
- (progress) => {
- console.log(progress);
- onProgress?.(progress);
- },
+ onProgress,
options.signal
);
- }
- break;
- case 'alac':
- blob = await ffmpeg(
- blob,
- { args: ['-c:a', 'alac'] },
- 'output.m4a',
- 'audio/mp4',
- (progress) => {
- console.log(progress);
- onProgress?.(progress);
- },
- options.signal
- );
- break;
- default:
- break;
+ break;
+ case 'alac':
+ blob = await ffmpeg(
+ blob,
+ { args: ['-c:a', 'alac'] },
+ 'output.m4a',
+ 'audio/mp4',
+ onProgress,
+ options.signal
+ );
+ break;
+ default:
+ break;
+ }
+ } catch (error) {
+ if (error?.name === 'AbortError') {
+ throw error;
+ }
+
+ console.error('Lossless container conversion failed:', error);
}
- } catch (error) {
- if (error?.name === 'AbortError') {
- throw error;
+ }
+
+ // Add metadata if track information is provided
+ if (track) {
+ if (onProgress) {
+ onProgress({
+ stage: 'processing',
+ message: 'Adding metadata...',
+ });
}
- console.error('Lossless container conversion failed:', error);
- }
- }
+ const enrichedTrack = { ...track };
+ if (lookup.info) {
+ enrichedTrack.replayGain = {
+ trackReplayGain: lookup.info.trackReplayGain,
+ trackPeakAmplitude: lookup.info.trackPeakAmplitude,
+ albumReplayGain: lookup.info.albumReplayGain,
+ albumPeakAmplitude: lookup.info.albumPeakAmplitude,
+ };
+ }
- // Add metadata if track information is provided
- if (track) {
- if (onProgress) {
- onProgress({
- stage: 'processing',
- message: 'Adding metadata...',
- });
+ blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
}
-
- const enrichedTrack = { ...track };
- if (lookup.info) {
- enrichedTrack.replayGain = {
- trackReplayGain: lookup.info.trackReplayGain,
- trackPeakAmplitude: lookup.info.trackPeakAmplitude,
- albumReplayGain: lookup.info.albumReplayGain,
- albumPeakAmplitude: lookup.info.albumPeakAmplitude,
- };
- }
-
- blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
}
// Detect actual format and fix filename extension if needed
@@ -1296,6 +1464,7 @@ export class LosslessAPI {
}
this.triggerDownload(blob, finalFilename);
+ return blob;
} catch (error) {
if (error.name === 'AbortError') {
throw error;
@@ -1331,23 +1500,10 @@ export class LosslessAPI {
return id;
}
- const formattedId = id.replace(/-/g, '/');
+ const formattedId = String(id).replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
- getVideoCoverUrl(id, size = '1280') {
- if (!id) {
- return null;
- }
-
- const parts = id.split('-');
- if (parts.length !== 5) {
- return null;
- }
-
- return `https://resources.tidal.com/videos/${parts[0]}/${parts[1]}/${parts[2]}/${parts[3]}/${parts[4]}/${size}x${size}.mp4`;
- }
-
getArtistPictureUrl(id, size = '320') {
if (!id) {
return `https://picsum.photos/seed/${Math.random()}/${size}`;
@@ -1357,7 +1513,7 @@ export class LosslessAPI {
return id;
}
- const formattedId = id.replace(/-/g, '/');
+ const formattedId = String(id).replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
diff --git a/js/app.js b/js/app.js
index ddf454d..eed389d 100644
--- a/js/app.js
+++ b/js/app.js
@@ -1963,20 +1963,23 @@ document.addEventListener('DOMContentLoaded', async () => {
db.getPlaylist(playlistId).then(async (playlist) => {
let trackId = null;
+ let trackType = null;
// Prefer ID if available (from sorted view)
if (btn.dataset.trackId) {
trackId = btn.dataset.trackId;
+ trackType = btn.dataset.type || 'track';
} else if (btn.dataset.trackIndex) {
// Fallback to index (legacy/unsorted)
const index = parseInt(btn.dataset.trackIndex);
if (playlist && playlist.tracks[index]) {
trackId = playlist.tracks[index].id;
+ trackType = playlist.tracks[index].type || 'track';
}
}
if (trackId) {
- const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId);
+ const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId, trackType);
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
const scrollTop = document.querySelector('.main-content').scrollTop;
await ui.renderPlaylistPage(playlistId, 'user');
@@ -2986,8 +2989,6 @@ function showCustomizeShortcutsModal() {
let recordingAction = null;
let recordingTimeout = null;
- const shortcuts = keyboardShortcuts.getShortcuts();
-
const formatKey = (key) => {
if (!key) return 'none';
const keyMap = {
diff --git a/js/db.js b/js/db.js
index eab226a..643328f 100644
--- a/js/db.js
+++ b/js/db.js
@@ -1,7 +1,7 @@
export class MusicDatabase {
constructor() {
this.dbName = 'MonochromeDB';
- this.version = 8;
+ this.version = 9;
this.db = null;
}
@@ -29,6 +29,10 @@ export class MusicDatabase {
const store = db.createObjectStore('favorites_tracks', { keyPath: 'id' });
store.createIndex('addedAt', 'addedAt', { unique: false });
}
+ if (!db.objectStoreNames.contains('favorites_videos')) {
+ const store = db.createObjectStore('favorites_videos', { keyPath: 'id' });
+ store.createIndex('addedAt', 'addedAt', { unique: false });
+ }
if (!db.objectStoreNames.contains('favorites_albums')) {
const store = db.createObjectStore('favorites_albums', { keyPath: 'id' });
store.createIndex('addedAt', 'addedAt', { unique: false });
@@ -88,7 +92,7 @@ export class MusicDatabase {
// History API
async addToHistory(track) {
const storeName = 'history_tracks';
- const minified = this._minifyItem('track', track);
+ const minified = this._minifyItem(track.type || 'track', track);
const timestamp = Date.now();
const entry = { ...minified, timestamp };
@@ -209,6 +213,7 @@ export class MusicDatabase {
_minifyItem(type, item) {
if (!item) return item;
+ const normalizedType = (type || '').toLowerCase();
// Base properties to keep
const base = {
@@ -216,7 +221,7 @@ export class MusicDatabase {
addedAt: item.addedAt || null,
};
- if (type === 'track') {
+ if (normalizedType === 'track') {
return {
...base,
title: item.title || null,
@@ -256,6 +261,19 @@ export class MusicDatabase {
};
}
+ if (normalizedType === 'video') {
+ return {
+ ...base,
+ type: 'video',
+ title: item.title || null,
+ duration: item.duration || null,
+ image: item.image || item.cover || null,
+ artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0] : null) || null,
+ artists: item.artists?.map((a) => ({ id: a.id, name: a.name || null })) || [],
+ album: item.album || { title: 'Video', cover: item.image || item.cover },
+ };
+ }
+
if (type === 'album') {
return {
...base,
@@ -548,7 +566,7 @@ export class MusicDatabase {
const playlist = {
id: id,
name: name,
- tracks: tracks.map((t) => this._minifyItem('track', { ...t, addedAt: Date.now() })),
+ tracks: tracks.map((t) => this._minifyItem(t.type || 'track', { ...t, addedAt: Date.now() })),
cover: cover,
description: description,
createdAt: Date.now(),
@@ -570,7 +588,7 @@ export class MusicDatabase {
if (!playlist) throw new Error('Playlist not found');
playlist.tracks = playlist.tracks || [];
const trackWithDate = { ...track, addedAt: Date.now() };
- const minifiedTrack = this._minifyItem('track', trackWithDate);
+ const minifiedTrack = this._minifyItem(track.type || 'track', trackWithDate);
if (playlist.tracks.some((t) => t.id === track.id)) return;
playlist.tracks.push(minifiedTrack);
playlist.updatedAt = Date.now();
@@ -591,7 +609,7 @@ export class MusicDatabase {
for (const track of tracks) {
if (!playlist.tracks.some((t) => t.id === track.id)) {
const trackWithDate = { ...track, addedAt: Date.now() };
- playlist.tracks.push(this._minifyItem('track', trackWithDate));
+ playlist.tracks.push(this._minifyItem(track.type || 'track', trackWithDate));
addedCount++;
}
}
@@ -606,11 +624,16 @@ export class MusicDatabase {
return playlist;
}
- async removeTrackFromPlaylist(playlistId, trackId) {
+ async removeTrackFromPlaylist(playlistId, trackId, trackType = null) {
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
if (!playlist) throw new Error('Playlist not found');
playlist.tracks = playlist.tracks || [];
- playlist.tracks = playlist.tracks.filter((t) => t.id != trackId);
+ playlist.tracks = playlist.tracks.filter((t) => {
+ if (trackType) {
+ return !(t.id == trackId && (t.type || 'track') === trackType);
+ }
+ return t.id != trackId;
+ });
playlist.updatedAt = Date.now();
this._updatePlaylistMetadata(playlist);
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
diff --git a/js/desktop/discord-rpc.js b/js/desktop/discord-rpc.js
index 40c8d3f..a1d8c6f 100644
--- a/js/desktop/discord-rpc.js
+++ b/js/desktop/discord-rpc.js
@@ -9,7 +9,7 @@ export function initializeDiscordRPC(player) {
let coverUrl = 'monochrome';
if (track.album?.cover) {
- const coverId = track.album.cover.replace(/-/g, '/');
+ const coverId = String(track.album.cover).replace(/-/g, '/');
coverUrl = `https://resources.tidal.com/images/${coverId}/320x320.jpg`;
}
diff --git a/js/downloads.js b/js/downloads.js
index 3f34e2c..cb437ce 100644
--- a/js/downloads.js
+++ b/js/downloads.js
@@ -195,11 +195,21 @@ export function updateDownloadProgress(trackId, progress) {
const percent = progress.totalBytes ? Math.round((progress.receivedBytes / progress.totalBytes) * 100) : 0;
progressFill.style.width = `${percent}%`;
+ progressFill.style.background = 'var(--highlight)';
const receivedMB = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
const totalMB = progress.totalBytes ? (progress.totalBytes / (1024 * 1024)).toFixed(1) : '?';
statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
+ } else if (progress.stage === 'encoding') {
+ const percent = progress.progress ? Math.round(progress.progress) : 0;
+ progressFill.style.width = `${percent}%`;
+ progressFill.style.background = '#3b82f6'; // Blue for encoding
+ statusEl.textContent = `Converting: ${percent}%`;
+ } else if (progress.stage === 'finalizing' || progress.stage === 'processing') {
+ progressFill.style.width = '100%';
+ progressFill.style.background = '#3b82f6';
+ statusEl.textContent = progress.message || 'Processing...';
}
}
@@ -268,7 +278,7 @@ function removeBulkDownloadTask(notifEl) {
}, 300);
}
-async function downloadTrackBlob(track, quality, api, lyricsManager = null, signal = null) {
+async function downloadTrackBlob(track, quality, api, lyricsManager = null, signal = null, onProgress = null) {
// Load ffmpeg in the background.
loadFfmpeg().catch(console.error);
@@ -348,7 +358,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
// Fallback
if (downloadQuality !== 'LOSSLESS') {
console.warn('Falling back to LOSSLESS (16-bit) download.');
- return downloadTrackBlob(track, 'LOSSLESS', api, lyricsManager, signal);
+ return downloadTrackBlob(track, 'LOSSLESS', api, lyricsManager, signal, onProgress);
}
throw dashError;
}
@@ -362,23 +372,21 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
// Convert to MP3 320kbps if requested
if (quality === 'MP3_320') {
- blob = await encodeToMp3(blob, console.log, signal);
+ blob = await encodeToMp3(blob, onProgress || (() => undefined), signal);
}
if (quality.endsWith('LOSSLESS')) {
try {
switch (losslessContainerSettings.getContainer()) {
case 'flac':
- if ((await getExtensionFromBlob(blob)) != 'flac') {
- blob = await ffmpeg(
- blob,
- { args: ['-c:a', 'copy'] },
- 'output.flac',
- 'audio/flac',
- console.log,
- signal
- );
- }
+ blob = await ffmpeg(
+ blob,
+ { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
+ 'output.flac',
+ 'audio/flac',
+ onProgress,
+ signal
+ );
break;
case 'alac':
blob = await ffmpeg(
@@ -386,7 +394,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
{ args: ['-c:a', 'alac'] },
'output.m4a',
'audio/mp4',
- console.log,
+ onProgress,
signal
);
break;
@@ -561,7 +569,9 @@ async function bulkDownloadToZipStream(
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
try {
- const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
+ const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
+ updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
+ });
const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i);
yield {
@@ -703,7 +713,9 @@ async function bulkDownloadToZipBlob(
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
try {
- const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
+ const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
+ updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
+ });
const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i);
yield {
@@ -846,7 +858,9 @@ async function bulkDownloadToZipNeutralino(
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
try {
- const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
+ const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
+ updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
+ });
const filename = buildTrackFilename(track, quality, extension);
const discNumber = discLayout.resolveDiscNumber(i);
yield {
@@ -1308,12 +1322,21 @@ function createBulkDownloadNotification(type, name, _totalItems) {
return notifEl;
}
-function updateBulkDownloadProgress(notifEl, current, total, currentItem) {
+function updateBulkDownloadProgress(notifEl, current, total, currentItem, ffmpegProgress = null) {
const progressFill = notifEl.querySelector('.download-progress-fill');
const statusEl = notifEl.querySelector('.download-status');
+ if (ffmpegProgress && (ffmpegProgress.stage === 'encoding' || ffmpegProgress.stage === 'finalizing')) {
+ const percent = ffmpegProgress.progress ? Math.round(ffmpegProgress.progress) : 100;
+ progressFill.style.width = `${percent}%`;
+ progressFill.style.background = '#3b82f6'; // Blue for encoding
+ statusEl.textContent = `Converting ${current}/${total}: ${percent}%`;
+ return;
+ }
+
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
progressFill.style.width = `${percent}%`;
+ progressFill.style.background = 'var(--highlight)';
statusEl.textContent = `${current}/${total} - ${currentItem}`;
}
diff --git a/js/events.js b/js/events.js
index 5a8af19..c2f9b2d 100644
--- a/js/events.js
+++ b/js/events.js
@@ -50,6 +50,7 @@ import {
trackSetSleepTimer,
trackCancelSleepTimer,
trackStartMix,
+ trackEvent,
} from './analytics.js';
let currentTrackIdForWaveform = null;
@@ -952,11 +953,13 @@ export async function handleTrackAction(
// Track like/unlike
if (added) {
if (type === 'track') trackLikeTrack(item);
+ else if (type === 'video') trackEvent('Like Video', { title: item.title });
else if (type === 'album') trackLikeAlbum(item);
else if (type === 'artist') trackLikeArtist(item);
else if (type === 'playlist' || type === 'user-playlist') trackLikePlaylist(item);
} else {
if (type === 'track') trackUnlikeTrack(item);
+ else if (type === 'video') trackEvent('Unlike Video', { title: item.title });
else if (type === 'album') trackUnlikeAlbum(item);
else if (type === 'artist') trackUnlikeArtist(item);
else if (type === 'playlist' || type === 'user-playlist') trackUnlikePlaylist(item);
@@ -976,7 +979,9 @@ export async function handleTrackAction(
const selector =
type === 'track'
? `[data-track-id="${id}"] .like-btn`
- : `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`;
+ : type === 'video'
+ ? `.card[data-video-id="${id}"] .like-btn`
+ : `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`;
// Also check header buttons
const headerBtn = document.getElementById(`like-${type}-btn`);
@@ -985,12 +990,12 @@ export async function handleTrackAction(
if (headerBtn) elementsToUpdate.push(headerBtn);
const nowPlayingLikeBtn = document.getElementById('now-playing-like-btn');
- if (nowPlayingLikeBtn && type === 'track' && player?.currentTrack?.id === item.id) {
+ if (nowPlayingLikeBtn && (type === 'track' || type === 'video') && player?.currentTrack?.id === item.id) {
elementsToUpdate.push(nowPlayingLikeBtn);
}
const fsLikeBtn = document.getElementById('fs-like-btn');
- if (fsLikeBtn && type === 'track' && player?.currentTrack?.id === item.id) {
+ if (fsLikeBtn && (type === 'track' || type === 'video') && player?.currentTrack?.id === item.id) {
elementsToUpdate.push(fsLikeBtn);
}
@@ -1011,7 +1016,9 @@ export async function handleTrackAction(
const itemSelector =
type === 'track'
? `.track-item[data-track-id="${id}"]`
- : `.card[data-${type}-id="${id}"], .card[data-playlist-id="${id}"]`;
+ : type === 'video'
+ ? `.video-card[data-video-id="${id}"]`
+ : `.card[data-${type}-id="${id}"], .card[data-playlist-id="${id}"]`;
const itemEl = document.querySelector(itemSelector);
@@ -1020,29 +1027,63 @@ export async function handleTrackAction(
const container = itemEl.parentElement;
itemEl.remove();
if (container && container.children.length === 0) {
- const msg = type === 'track' ? 'No liked tracks yet.' : `No liked ${type}s yet.`;
+ const msg =
+ type === 'track'
+ ? 'No liked tracks yet.'
+ : type === 'video'
+ ? 'No liked videos yet.'
+ : `No liked ${type}s yet.`;
container.innerHTML = `${msg}
`;
}
- } else if (added && !itemEl && ui && type === 'track') {
- // Add item (specifically for tracks currently)
- const tracksContainer = document.getElementById('library-tracks-container');
- if (tracksContainer) {
- // Remove placeholder if it exists
- const placeholder = tracksContainer.querySelector('.placeholder-text');
- if (placeholder) placeholder.remove();
+ } else if (added && !itemEl && ui && (type === 'track' || type === 'video')) {
+ // Add item
+ if (type === 'track') {
+ const tracksContainer = document.getElementById('library-tracks-container');
+ if (tracksContainer) {
+ const placeholder = tracksContainer.querySelector('.placeholder-text');
+ if (placeholder) placeholder.remove();
- // Create track element
- const index = tracksContainer.children.length;
- const trackHTML = ui.createTrackItemHTML(item, index, true, false);
+ const index = tracksContainer.children.length;
+ const trackHTML = ui.createTrackItemHTML(item, index, true, false);
- const tempDiv = document.createElement('div');
- tempDiv.innerHTML = trackHTML;
- const newEl = tempDiv.firstElementChild;
+ const tempDiv = document.createElement('div');
+ tempDiv.innerHTML = trackHTML;
+ const newEl = tempDiv.firstElementChild;
- if (newEl) {
- tracksContainer.appendChild(newEl);
- trackDataStore.set(newEl, item);
- ui.updateLikeState(newEl, 'track', item.id);
+ if (newEl) {
+ tracksContainer.appendChild(newEl);
+ trackDataStore.set(newEl, item);
+ ui.updateLikeState(newEl, 'track', item.id);
+ }
+ }
+ } else if (type === 'video') {
+ const videosTabContent = document.getElementById('library-tab-videos');
+ if (videosTabContent) {
+ const grid = videosTabContent.querySelector('.card-grid');
+ if (grid) {
+ const placeholder = grid.querySelector('.placeholder-text');
+ if (placeholder) grid.innerHTML = '';
+
+ const videoHTML = ui.createVideoCardHTML(item);
+ const tempDiv = document.createElement('div');
+ tempDiv.innerHTML = videoHTML;
+ const newEl = tempDiv.firstElementChild;
+
+ if (newEl) {
+ grid.appendChild(newEl);
+ trackDataStore.set(newEl, item);
+ ui.updateLikeState(newEl, 'video', item.id);
+ newEl.addEventListener('click', (e) => {
+ if (
+ e.target.closest('.card-play-btn') ||
+ e.target.closest('.card-image-container')
+ ) {
+ e.stopPropagation();
+ player.playVideo(item);
+ }
+ });
+ }
+ }
}
}
}
@@ -1058,10 +1099,14 @@ export async function handleTrackAction(
// Removed empty check to allow creating new playlist
const trackId = item.id;
+ const trackType = item.type || 'track';
const playlistsWithTrack = new Set();
for (const playlist of playlists) {
- if (playlist.tracks && playlist.tracks.some((track) => track.id == trackId)) {
+ if (
+ playlist.tracks &&
+ playlist.tracks.some((t) => t.id == trackId && (t.type || 'track') === trackType)
+ ) {
playlistsWithTrack.add(playlist.id);
}
}
@@ -1435,9 +1480,10 @@ async function updateContextMenuLikeState(contextMenu, contextTrack) {
const type = contextMenu._contextType || 'track';
const likeItem = contextMenu.querySelector('li[data-action="toggle-like"]');
+ let isLiked = false;
if (likeItem) {
- const isLiked = await db.isFavorite(type, contextTrack.id);
- likeItem.textContent = isLiked ? 'Unlike' : 'Like';
+ const key = type === 'playlist' ? contextTrack.uuid : contextTrack.id;
+ isLiked = await db.isFavorite(type, key);
}
const pinItem = contextMenu.querySelector('li[data-action="toggle-pin"]');
@@ -1500,8 +1546,10 @@ async function updateContextMenuLikeState(contextMenu, contextTrack) {
// Update labels for Like/Save
if (item.dataset.action === 'toggle-like') {
- const labelKey = `label${type.charAt(0).toUpperCase() + type.slice(1).replace('User-playlist', 'Playlist')}`;
- const label = item.dataset[labelKey] || item.dataset.labelTrack || 'Like';
+ const labelPrefix = isLiked ? 'labelUnlike' : 'label';
+ const labelKey = `${labelPrefix}${type.charAt(0).toUpperCase() + type.slice(1).replace('User-playlist', 'Playlist')}`;
+ const fallbackKey = isLiked ? 'labelUnlikeTrack' : 'labelTrack';
+ const label = item.dataset[labelKey] || item.dataset[fallbackKey] || (isLiked ? 'Unlike' : 'Like');
item.textContent = label;
}
});
@@ -1642,7 +1690,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
contextMenu._originalHTML = null;
}
contextMenu._contextTrack = contextTrack;
- contextMenu._contextType = 'track';
+ contextMenu._contextType = menuBtn.dataset.type || trackItem.dataset.type || 'track';
await updateContextMenuLikeState(contextMenu, contextTrack);
const rect = menuBtn.getBoundingClientRect();
positionMenu(contextMenu, rect.left, rect.bottom + 5, rect);
@@ -1667,15 +1715,19 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
if (isSearch) {
const clickedTrack = trackDataStore.get(trackItem);
if (clickedTrack) {
- player.setQueue([clickedTrack], 0);
- document.getElementById('shuffle-btn').classList.remove('active');
- player.playTrackFromQueue();
+ if (trackItem.dataset.type === 'video') {
+ player.playVideo(clickedTrack);
+ } else {
+ player.setQueue([clickedTrack], 0);
+ document.getElementById('shuffle-btn').classList.remove('active');
+ player.playTrackFromQueue();
- api.getTrackRecommendations(clickedTrack.id).then((recs) => {
- if (recs && recs.length > 0) {
- player.addToQueue(recs);
- }
- });
+ api.getTrackRecommendations(clickedTrack.id).then((recs) => {
+ if (recs && recs.length > 0) {
+ player.addToQueue(recs);
+ }
+ });
+ }
}
} else {
const parentList = trackItem.closest('.track-list');
@@ -1760,7 +1812,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
});
contextMenu._contextTrack = contextTrack;
- contextMenu._contextType = 'track';
+ contextMenu._contextType = contextTrack.type || 'track';
await updateContextMenuLikeState(contextMenu, contextTrack);
positionMenu(contextMenu, e.clientX, e.clientY);
}
@@ -1916,7 +1968,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
player,
api,
lyricsManager,
- 'track',
+ player.currentTrack.type || 'track',
ui,
scrobbler
);
@@ -1954,7 +2006,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
player,
api,
lyricsManager,
- 'track',
+ player.currentTrack.type || 'track',
ui,
scrobbler
);
@@ -1975,7 +2027,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
player,
api,
lyricsManager,
- 'track',
+ player.currentTrack.type || 'track',
ui,
scrobbler
);
diff --git a/js/hls-downloader.js b/js/hls-downloader.js
new file mode 100644
index 0000000..a05aa1e
--- /dev/null
+++ b/js/hls-downloader.js
@@ -0,0 +1,107 @@
+export class HlsDownloader {
+ constructor() {}
+
+ async downloadHlsStream(masterUrl, options = {}) {
+ const { onProgress, signal } = options;
+
+ const response = await fetch(masterUrl, { signal });
+ const masterText = await response.text();
+
+ const variantUrl = this.getBestVariantUrl(masterUrl, masterText);
+
+ const mediaResponse = await fetch(variantUrl, { signal });
+ const mediaText = await mediaResponse.text();
+
+ const segments = this.parseMediaPlaylist(variantUrl, mediaText);
+ if (segments.length === 0) {
+ throw new Error('No segments found in HLS playlist');
+ }
+
+ const chunks = [];
+ let downloadedBytes = 0;
+ const totalSegments = segments.length;
+
+ for (let i = 0; i < totalSegments; i++) {
+ if (signal?.aborted) throw new Error('AbortError');
+
+ const segmentUrl = segments[i];
+ const segmentResponse = await fetch(segmentUrl, { signal });
+
+ if (!segmentResponse.ok) {
+ throw new Error(`Failed to fetch segment ${i}: ${segmentResponse.status}`);
+ }
+
+ const chunk = await segmentResponse.arrayBuffer();
+ chunks.push(chunk);
+ downloadedBytes += chunk.byteLength;
+
+ if (onProgress) {
+ onProgress({
+ stage: 'downloading',
+ receivedBytes: downloadedBytes,
+ totalBytes: undefined,
+ currentSegment: i + 1,
+ totalSegments: totalSegments,
+ });
+ }
+ }
+
+ const mimeType = segments[0].endsWith('.m4s') || segments[0].includes('mp4') ? 'video/mp4' : 'video/mp2t';
+ return new Blob(chunks, { type: mimeType });
+ }
+
+ getBestVariantUrl(masterUrl, masterText) {
+ if (!masterText.includes('#EXT-X-STREAM-INF')) {
+ return masterUrl;
+ }
+
+ const lines = masterText.split('\n');
+ const variants = [];
+ let currentVariant = null;
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed.startsWith('#EXT-X-STREAM-INF:')) {
+ const bandwidthMatch = trimmed.match(/BANDWIDTH=(\d+)/);
+ const resolutionMatch = trimmed.match(/RESOLUTION=(\d+x\d+)/);
+ currentVariant = {
+ bandwidth: bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0,
+ resolution: resolutionMatch ? resolutionMatch[1] : 'unknown',
+ };
+ } else if (trimmed && !trimmed.startsWith('#')) {
+ if (currentVariant) {
+ currentVariant.url = this.resolveUrl(masterUrl, trimmed);
+ variants.push(currentVariant);
+ currentVariant = null;
+ }
+ }
+ }
+
+ if (variants.length === 0) return masterUrl;
+
+ variants.sort((a, b) => b.bandwidth - a.bandwidth);
+ return variants[0].url;
+ }
+
+ parseMediaPlaylist(mediaUrl, mediaText) {
+ const lines = mediaText.split('\n');
+ const segments = [];
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed && !trimmed.startsWith('#')) {
+ segments.push(this.resolveUrl(mediaUrl, trimmed));
+ }
+ }
+
+ return segments;
+ }
+
+ resolveUrl(baseUrl, relativeUrl) {
+ try {
+ return new URL(relativeUrl, baseUrl).href;
+ } catch {
+ return relativeUrl;
+ }
+ }
+}
diff --git a/js/lyrics.js b/js/lyrics.js
index 922b2eb..8cdf87f 100644
--- a/js/lyrics.js
+++ b/js/lyrics.js
@@ -779,9 +779,6 @@ export function openLyricsPanel(track, audioPlayer, lyricsManager, forceOpen = f
const offsetDisplay = manager.getOffsetDisplayString(manager.timingOffset);
container.innerHTML = `
-