Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 93 additions & 10 deletions js/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export class LosslessAPI {
return response;
}

const shouldTryNative = type !== 'streaming';
const shouldTryNative = type !== 'streaming' || localStorage.getItem('allTidal') === 'true';

if (shouldTryNative) {
try {
Expand Down Expand Up @@ -289,6 +289,16 @@ export class LosslessAPI {
normalized = { ...normalized, audioQuality: derivedQuality };
}

const copyrightText =
typeof normalized.copyright === 'string'
? normalized.copyright
: normalized.copyright && typeof normalized.copyright === 'object'
? normalized.copyright.text
: normalized.copyright;
if (copyrightText !== normalized.copyright) {
normalized = { ...normalized, copyright: copyrightText ?? '' };
}

normalized.isUnavailable = isTrackUnavailable(normalized);

return normalized.type == 'video' ? new PreparedVideo(normalized) : new PreparedTrack(normalized);
Expand All @@ -312,6 +322,20 @@ export class LosslessAPI {
normalized.artist = video.artists[0];
}

if (!normalized.imageId) {
const imageCandidate = video.imageId || video.squareImage || video.image || video.cover;
if (typeof imageCandidate === 'string' || typeof imageCandidate === 'number') {
normalized.imageId = imageCandidate;
}
}

if (!normalized.image) {
const imageCandidate = video.image || video.squareImage || video.cover || normalized.imageId;
if (typeof imageCandidate === 'string' || typeof imageCandidate === 'number') {
normalized.image = imageCandidate;
}
}

return normalized;
}

Expand Down Expand Up @@ -1133,6 +1157,23 @@ export class LosslessAPI {
};

if (!options.lightweight) {
try {
// v2 /artist?id can return a partial album relationship set; merge with
// the dedicated releases route to avoid dropping albums on artist pages.
const releasesResponse = await this.fetchWithRetry(`/artist/?f=${artistId}&skip_tracks=true`);
const releasesJson = await releasesResponse.json();
const releasesData = releasesJson.data || releasesJson;
const releaseItems = releasesData?.albums?.items || [];
for (const entry of releaseItems) {
const release = entry?.item || entry;
if (release?.id) {
albumMap.set(release.id, this.prepareAlbum(release));
}
}
} catch (e) {
console.warn('Failed to fetch additional artist releases:', e);
}

try {
const videoSearch = await this.searchVideos(artist.name);
if (videoSearch && videoSearch.items) {
Expand All @@ -1147,18 +1188,31 @@ export class LosslessAPI {
}
}

const rawReleases = Array.from(albumMap.values()).filter(matchesArtistId);
const topTracksPool = Array.from(trackMap.values()).filter(matchesArtistId);
for (const track of topTracksPool) {
if (!track?.album?.id || albumMap.has(track.album.id)) continue;
albumMap.set(
track.album.id,
this.prepareAlbum({
...track.album,
artist: track.artist || track.album.artist,
artists: track.artists?.length ? track.artists : track.album.artists,
})
);
}

const topTrackAlbumIds = new Set(topTracksPool.map((track) => Number(track?.album?.id)).filter(Boolean));
const rawReleases = Array.from(albumMap.values()).filter(
(album) => matchesArtistId(album) || topTrackAlbumIds.has(Number(album?.id))
);
Comment on lines +1204 to +1207
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

.filter(Boolean) on numeric album IDs drops the id 0.

Number(track?.album?.id) returns 0 for an album id of 0 or "0", and .filter(Boolean) then strips it from the set. The rawReleases filter compares against this set with topTrackAlbumIds.has(Number(album?.id)), so albums with id 0 would be excluded from the artist page. Album 0 is unlikely in practice, but the safer predicate is an explicit nullish/NaN check to match the style of matchesArtistId.

🔧 Suggested tweak
-        const topTrackAlbumIds = new Set(topTracksPool.map((track) => Number(track?.album?.id)).filter(Boolean));
+        const topTrackAlbumIds = new Set(
+            topTracksPool
+                .map((track) => (track?.album?.id != null ? Number(track.album.id) : null))
+                .filter((id) => id != null && !Number.isNaN(id))
+        );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const topTrackAlbumIds = new Set(topTracksPool.map((track) => Number(track?.album?.id)).filter(Boolean));
const rawReleases = Array.from(albumMap.values()).filter(
(album) => matchesArtistId(album) || topTrackAlbumIds.has(Number(album?.id))
);
const topTrackAlbumIds = new Set(
topTracksPool
.map((track) => (track?.album?.id != null ? Number(track.album.id) : null))
.filter((id) => id != null && !Number.isNaN(id))
);
const rawReleases = Array.from(albumMap.values()).filter(
(album) => matchesArtistId(album) || topTrackAlbumIds.has(Number(album?.id))
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/api.js` around lines 1204 - 1207, The current creation of topTrackAlbumIds
uses .filter(Boolean) which removes numeric id 0; change the predicate to
explicitly exclude null/undefined and NaN instead. Update the topTrackAlbumIds
construction (the topTracksPool map -> Number(track?.album?.id) ->
.filter(Boolean)) to use a check like id != null && !Number.isNaN(id) (or
Number.isFinite) so albums with id 0 are kept; leave the rawReleases filtering
logic (matchesArtistId and topTrackAlbumIds.has(Number(album?.id))) intact so
behavior matches matchesArtistId's nullish/NaN style.

const allReleases = this.deduplicateAlbums(rawReleases).sort(
(a, b) => new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
);

const eps = allReleases.filter((a) => a.type === 'EP' || a.type === 'SINGLE');
const albums = allReleases.filter((a) => !eps.includes(a));

const topTracks = Array.from(trackMap.values())
.filter(matchesArtistId)
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
.slice(0, 15);
const topTracks = topTracksPool.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)
Expand Down Expand Up @@ -1614,7 +1668,36 @@ export class LosslessAPI {
}

const id = input?.id || input;
const track = typeof input === 'object' ? input : await this.getTrack(id, downloadQuality);
const hasMissingDownloadMetadata = (candidate) =>
candidate?.trackNumber == null ||
(candidate?.volumeNumber == null && candidate?.discNumber == null) ||
candidate?.album?.numberOfTracks == null;

let track = typeof input === 'object' ? this.prepareTrack(input) : await this.getTrack(id, downloadQuality);
if (
typeof input === 'object' &&
!track?.type?.toLowerCase?.().includes('video') &&
hasMissingDownloadMetadata(track)
) {
try {
const fullTrack = await this.getTrackMetadata(id);
track = this.prepareTrack({
...fullTrack,
...track,
trackNumber: track?.trackNumber ?? fullTrack?.trackNumber,
volumeNumber: track?.volumeNumber ?? fullTrack?.volumeNumber,
discNumber: track?.discNumber ?? fullTrack?.discNumber,
album: {
...(fullTrack?.album || {}),
...(track?.album || {}),
},
Comment on lines +1690 to +1693
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve hydrated album fields when the original value is nullish.

Because track.album is spread after fullTrack.album, an incomplete source with album.numberOfTracks: null overwrites the hydrated value. That leaves the exact missing metadata from Lines 1641-1644 unresolved.

🔧 Proposed fix
                     album: {
                         ...(fullTrack?.album || {}),
                         ...(track?.album || {}),
+                        numberOfTracks: track?.album?.numberOfTracks ?? fullTrack?.album?.numberOfTracks,
                     },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
album: {
...(fullTrack?.album || {}),
...(track?.album || {}),
},
album: {
...(fullTrack?.album || {}),
...(track?.album || {}),
numberOfTracks: track?.album?.numberOfTracks ?? fullTrack?.album?.numberOfTracks,
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/api.js` around lines 1660 - 1663, The current album merge spreads
track.album after fullTrack.album which lets null/undefined fields in track
overwrite hydrated values; change the merge so only defined (non-nullish)
properties from track.album override fullTrack.album—e.g., build a filtered
version of track.album that excludes null/undefined values (or use a helper like
compactObject) and then spread fullTrack.album first and the filtered trackAlbum
second in the album object (refer to the album merge where fullTrack and track
are spread).

artist: track?.artist || fullTrack?.artist,
artists: track?.artists?.length ? track.artists : fullTrack?.artists,
});
} catch (e) {
console.warn('Failed to hydrate full track metadata for download:', e);
}
}
const isVideo = track?.type?.toLowerCase().includes('video');
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enrichTrack() computes isVideo via track?.type?.toLowerCase().includes('video'), but type is not guaranteed to be present on prepared track objects (e.g., when prepareTrack() is called on an input object without a type field). If track.type is null/undefined this will throw because .includes(...) is invoked on undefined. Make this null-safe (e.g., use track?.type?.toLowerCase?.().includes('video') or coerce with (track?.type || '')).

Suggested change
const isVideo = track?.type?.toLowerCase().includes('video');
const isVideo = (track?.type?.toLowerCase?.() || '').includes('video');

Copilot uses AI. Check for mistakes.
downloadQuality = isCustomFormat(downloadQuality) ? 'LOSSLESS' : downloadQuality;

Expand Down Expand Up @@ -1774,7 +1857,7 @@ export class LosslessAPI {
if (streamUrl.startsWith('blob:')) {
try {
const downloader = new DashDownloader();
blob = await downloader.downloadDashStream(getProxyUrl(streamUrl), {
blob = await downloader.downloadDashStream(streamUrl, {
signal: options.signal,
onProgress,
calculateDashBytes: calculateDashBytes ?? true,
Expand All @@ -1793,7 +1876,7 @@ export class LosslessAPI {
} else if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) {
try {
const downloader = new HlsDownloader();
blob = await downloader.downloadHlsStream(getProxyUrl(streamUrl), {
blob = await downloader.downloadHlsStream(streamUrl, {
signal: options.signal,
onProgress,
});
Expand All @@ -1818,7 +1901,7 @@ export class LosslessAPI {
/* ignore HEAD failure; proceed with GET */
}

const response = await fetch(getProxyUrl(streamUrl), {
const response = await fetch(streamUrl, {
cache: 'no-store',
signal: options.signal,
});
Expand Down
3 changes: 3 additions & 0 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ import {
SVG_RESET,
} from './icons.js';
import { HiFiClient } from './HiFi.js';
import { patchFetch } from './proxy-utils';

patchFetch();

// Capture real iOS state before spoofing (needed for background audio)
if (typeof window !== 'undefined') {
Expand Down
5 changes: 3 additions & 2 deletions js/dash-downloader.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AbortError } from './errorTypes';
import { SegmentedDownloadProgress } from './progressEvents';
import { getProxyUrl } from './proxy-utils';

export interface DashDownloadOptions {
onProgress?: MonochromeProgressListener<SegmentedDownloadProgress>;
Expand Down Expand Up @@ -30,7 +31,7 @@ export class DashDownloader {

await Promise.all(
urls.map(async (url) => {
const result = await fetch(getProxyUrl(url), { method: 'HEAD', signal });
const result = await fetch(url, { method: 'HEAD', signal });

if (result.ok) {
const contentLength = result.headers.get('Content-Length');
Expand Down Expand Up @@ -75,7 +76,7 @@ export class DashDownloader {

onProgress?.(new SegmentedDownloadProgress(downloadedBytes, totalSize ?? undefined, i, totalSegments));

const url = getProxyUrl(urls[i]);
const url = urls[i];
const segmentResponse = await fetch(url, { signal });

if (!segmentResponse.ok) {
Expand Down
34 changes: 25 additions & 9 deletions js/downloads.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,19 @@ function removeBulkDownloadTask(notifEl) {
}

async function downloadTrackBlob(track, quality, api, signal = null, onProgress = null) {
const tidalAPI = api.tidalAPI || api;
let downloadTrack = track;
try {
if (typeof tidalAPI.enrichTrack === 'function') {
const { enrichedTrack } = await tidalAPI.enrichTrack(track, { downloadQuality: quality });
if (enrichedTrack) downloadTrack = enrichedTrack;
}
} catch (e) {
console.warn('Failed to enrich track metadata before bulk download:', e);
}

const blob = await api.downloadTrack(track.id, quality, undefined, {
track,
track: downloadTrack,
signal,
onProgress,
triggerDownload: false,
Expand All @@ -326,7 +337,7 @@ async function downloadTrackBlob(track, quality, api, signal = null, onProgress
// Detect actual format from blob signature BEFORE adding metadata
const extension = await getExtensionFromBlob(blob);

return { blob, extension };
return { blob, extension, track: downloadTrack };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Apply the enriched-track return value to discography downloads too.

downloadTrackBlob() now returns the effective track, but downloadDiscography() still ignores it and builds filenames/lyrics from the original track at Lines 795-812. Discography exports can still miss the metadata this PR is trying to hydrate.

🔧 Proposed follow-up fix for the discography caller
-                        const { blob, extension } = await downloadTrackBlob(track, quality, api, signal, null);
-                        const filename = buildTrackFilename(track, quality, extension);
+                        const {
+                            blob,
+                            extension,
+                            track: enrichedTrack,
+                        } = await downloadTrackBlob(track, quality, api, signal, null);
+                        const effectiveTrack = enrichedTrack || track;
+                        const filename = buildTrackFilename(effectiveTrack, quality, extension);
-                                const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
+                                const lyricsData = await lyricsManager.fetchLyrics(effectiveTrack.id, effectiveTrack);
                                 if (lyricsData) {
-                                    const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
+                                    const lrcContent = lyricsManager.generateLRCContent(lyricsData, effectiveTrack);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/downloads.js` at line 340, downloadDiscography currently calls
downloadTrackBlob but ignores the enriched track returned, continuing to build
filenames/lyrics from the original track; update downloadDiscography to
await/accept the returned object from downloadTrackBlob (blob, extension, track)
and use that returned track when constructing filenames, metadata and lyric
files instead of the original input track so discography exports include the
hydrated metadata; ensure variable names in downloadDiscography that previously
referenced the input track are replaced with the returned track and handle any
null/undefined track cases consistently.

}

async function bulkDownload({
Expand Down Expand Up @@ -365,7 +376,11 @@ async function bulkDownload({
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);

try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, signal, (p) => {
const {
blob,
extension,
track: enrichedTrack,
} = await downloadTrackBlob(track, quality, api, signal, (p) => {
Comment on lines +379 to +383
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In bulkDownload(), the destructured track: enrichedTrack can actually be the original input track when enrichment fails (since downloadTrackBlob() falls back), so the name is misleading. Consider renaming this destructured value to something like downloadTrack / trackForMetadata to reflect what it contains.

Copilot uses AI. Check for mistakes.
if (p instanceof DownloadProgress && p.totalBytes && p.receivedBytes) {
fileFraction = p.receivedBytes / p.totalBytes;
} else if (p instanceof SegmentedDownloadProgress && p.currentSegment && p.totalSegments) {
Expand All @@ -375,7 +390,8 @@ async function bulkDownload({
fileFraction = Math.min(fileFraction, 0.99); // Cap at 99% to avoid showing 100% before finalization
updateBulkDownloadProgress(notification, i + fileFraction, tracks.length, trackTitle, p);
});
const filename = buildTrackFilename(track, quality, extension);
const effectiveTrack = enrichedTrack || track;
const filename = buildTrackFilename(effectiveTrack, quality, extension);
Comment on lines +393 to +394
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const effectiveTrack = enrichedTrack || track; is redundant here because downloadTrackBlob() always returns a non-null track value. Simplifying this avoids implying that enrichedTrack might be missing at this point.

Copilot uses AI. Check for mistakes.
const discNumber = discLayout.resolveDiscNumber(i);
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;

Expand All @@ -389,9 +405,9 @@ async function bulkDownload({

if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
const lyricsData = await lyricsManager.fetchLyrics(effectiveTrack.id, effectiveTrack);
if (lyricsData) {
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
const lrcContent = lyricsManager.generateLRCContent(lyricsData, effectiveTrack);
if (lrcContent) {
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
yield {
Expand Down Expand Up @@ -1079,7 +1095,7 @@ export async function downloadTrackWithMetadata(
triggerDownload: false,
});

const finalFilename = buildTrackFilename(track, quality, await getExtensionFromBlob(blob))
const finalFilename = buildTrackFilename(enrichedTrack, quality, await getExtensionFromBlob(blob))
.split('/')
.pop();

Expand All @@ -1102,13 +1118,13 @@ export async function downloadTrackWithMetadata(

if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
const lyricsData = await lyricsManager.fetchLyrics(enrichedTrack.id, enrichedTrack);
if (lyricsData) {
await folderWriter.write(
singleWriterEntry({
name: [...entryName.split('.').slice(0, -1), 'lrc'].join('.'),
lastModified: new Date(),
input: lyricsManager.getLRC(lyricsData, track),
input: lyricsManager.getLRC(lyricsData, enrichedTrack),
})
);
}
Expand Down
26 changes: 26 additions & 0 deletions js/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -2273,6 +2273,32 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
return;
}

if (card.dataset.videoId) {
if (card.classList.contains('blocked')) return;
if (e.target.closest('.like-btn') || e.target.closest('.card-menu-btn')) {
return;
}

e.preventDefault();
const clickedVideo = trackDataStore.get(card);
if (!clickedVideo) return;

const parentContainer = card.parentElement || mainContent;
const allVideoElements = Array.from(parentContainer.querySelectorAll('.video-card[data-video-id]'));
const videoList = allVideoElements.map((el) => trackDataStore.get(el)).filter(Boolean);

if (videoList.length > 0) {
const startIndex = videoList.findIndex((v) => String(v.id) === String(card.dataset.videoId));
player.setQueue(videoList, startIndex >= 0 ? startIndex : 0);
player.enableAutoplay();
document.getElementById('shuffle-btn').classList.remove('active');
player.playTrackFromQueue();
} else {
player.playVideo(clickedVideo);
}
return;
}

const href = card.dataset.href;
if (href) {
// Allow native links inside card to work if any exist
Expand Down
1 change: 1 addition & 0 deletions js/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ type WithRequiredKeys<T> = {

declare global {
const __COMMIT_HASH__: string | undefined;
const __VITE_PROXY__: string | undefined;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the injected __VITE_PROXY__ value and ambient declaration stay aligned.
rg -n -C2 '__VITE_PROXY__' vite.config.ts js/global.d.ts js/proxy-utils.js

Repository: monochrome-music/monochrome

Length of output: 866


Align the global type with the injected value.

vite.config.ts injects either a string ('/proxy-audio') or null, but the ambient declaration is string | undefined. Change this to string | null to match the actual runtime type.

🛠️ Proposed type fix
-    const __VITE_PROXY__: string | undefined;
+    const __VITE_PROXY__: string | null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const __VITE_PROXY__: string | undefined;
const __VITE_PROXY__: string | null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/global.d.ts` at line 37, Update the ambient declaration for the injected
Vite value __VITE_PROXY__ so its type matches the runtime injection (null or
string): locate the const __VITE_PROXY__ declaration in the global type file and
change its type from string | undefined to string | null so consumers and
type-checking align with the actual injected value.

}
6 changes: 3 additions & 3 deletions js/hls-downloader.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ export class HlsDownloader {
async downloadHlsStream(masterUrl, options = {}) {
const { onProgress, signal } = options;

const response = await fetch(getProxyUrl(masterUrl), { signal });
const response = await fetch(masterUrl, { signal });
const masterText = await response.text();

const variantUrl = this.getBestVariantUrl(masterUrl, masterText);

const mediaResponse = await fetch(getProxyUrl(variantUrl), { signal });
const mediaResponse = await fetch(variantUrl, { signal });
const mediaText = await mediaResponse.text();

const segments = this.parseMediaPlaylist(variantUrl, mediaText);
Expand All @@ -30,7 +30,7 @@ export class HlsDownloader {
onProgress?.(new SegmentedDownloadProgress(downloadedBytes, undefined, i, totalSegments));

const segmentUrl = segments[i];
const segmentResponse = await fetch(getProxyUrl(segmentUrl), { signal });
const segmentResponse = await fetch(segmentUrl, { signal });

if (!segmentResponse.ok) {
throw new Error(`Failed to fetch segment ${i}: ${segmentResponse.status}`);
Expand Down
2 changes: 1 addition & 1 deletion js/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export class Player {
const uris = request.uris;
for (let i = 0; i < uris.length; i++) {
if (uris[i].includes('tidal.com')) {
uris[i] = getProxyUrl(uris[i]);
uris[i] = uris[i];
}
}
}
Expand Down
Loading