Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
57 changes: 55 additions & 2 deletions js/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,16 @@
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 @@
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 @@ -1614,7 +1638,36 @@
}

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');

Copilot AI Apr 19, 2026

Copy link

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 +1827,7 @@
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 Down Expand Up @@ -1906,7 +1959,7 @@
}
console.error('Download failed:', error);
if (error instanceof FfmpegError || error.code === 'MP3_ENCODING_FAILED') {
throw error;

Check failure on line 1962 in js/api.js

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'Low'

Error: Download failed. The stream may require a proxy. ❯ LosslessAPI.downloadTrack js/api.js:1962:18 ❯ downloadTrack js/api.test.ts:109:15 ❯ js/api.test.ts:282:21

Check failure on line 1962 in js/api.js

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'High'

Error: Download failed. The stream may require a proxy. ❯ LosslessAPI.downloadTrack js/api.js:1962:18 ❯ downloadTrack js/api.test.ts:109:15 ❯ js/api.test.ts:282:21

Check failure on line 1962 in js/api.js

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'Lossless, but not really'

Error: Download failed. The stream may require a proxy. ❯ LosslessAPI.downloadTrack js/api.js:1962:18 ❯ downloadTrack js/api.test.ts:109:15 ❯ js/api.test.ts:282:21

Check failure on line 1962 in js/api.js

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'Lossless (Unchanged)'

Error: Download failed. The stream may require a proxy. ❯ LosslessAPI.downloadTrack js/api.js:1962:18 ❯ downloadTrack js/api.test.ts:109:15 ❯ js/api.test.ts:282:21

Check failure on line 1962 in js/api.js

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'HD Lossless (Unchanged)'

Error: Download failed. The stream may require a proxy. ❯ LosslessAPI.downloadTrack js/api.js:1962:18 ❯ downloadTrack js/api.test.ts:109:15 ❯ js/api.test.ts:282:21

Check failure on line 1962 in js/api.js

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'Lossless (ALAC)'

Error: Download failed. The stream may require a proxy. ❯ LosslessAPI.downloadTrack js/api.js:1962:18 ❯ downloadTrack js/api.test.ts:109:15 ❯ js/api.test.ts:282:21

Check failure on line 1962 in js/api.js

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'HD Lossless (ALAC)'

Error: Download failed. The stream may require a proxy. ❯ LosslessAPI.downloadTrack js/api.js:1962:18 ❯ downloadTrack js/api.test.ts:109:15 ❯ js/api.test.ts:282:21

Check failure on line 1962 in js/api.js

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'Lossless (FLAC)'

Error: Download failed. The stream may require a proxy. ❯ LosslessAPI.downloadTrack js/api.js:1962:18 ❯ downloadTrack js/api.test.ts:109:15 ❯ js/api.test.ts:282:21

Check failure on line 1962 in js/api.js

View workflow job for this annotation

GitHub Actions / test

[chromium] js/api.test.ts > Track Downloads > 'HD Lossless (FLAC)'

Error: Download failed. The stream may require a proxy. ❯ LosslessAPI.downloadTrack js/api.js:1962:18 ❯ downloadTrack js/api.test.ts:109:15 ❯ js/api.test.ts:282:21
}
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
throw error;
Expand Down
1 change: 1 addition & 0 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
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

Copilot AI Apr 19, 2026

Copy link

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

Copilot AI Apr 19, 2026

Copy link

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
6 changes: 3 additions & 3 deletions js/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -820,12 +820,12 @@ export class UIRenderer {
const duration = formatTime(video.duration);
const artistName = getTrackArtists(video);

const videoCoverCandidate = video.imageId || video.image || video.cover || null;
const videoCoverCandidate = video.imageId || video.image || video.squareImage || video.cover || null;
const videoCoverUrl =
videoCoverCandidate && (typeof videoCoverCandidate === 'string' || typeof videoCoverCandidate === 'number')
? this.api.getVideoCoverUrl(videoCoverCandidate)
: null;
const coverFallback = video.image || video.cover;
const coverFallback = video.image || video.squareImage || video.cover;
const coverPrimitive =
coverFallback != null && (typeof coverFallback === 'string' || typeof coverFallback === 'number')
? coverFallback
Expand Down Expand Up @@ -5513,7 +5513,7 @@ export class UIRenderer {
const el = videosContainer.querySelector(`[data-video-id="${video.id}"]`);
if (el) {
trackDataStore.set(el, video);
await this.updateLikeState(el, 'track', video.id);
await this.updateLikeState(el, 'video', video.id);
}
}
} else {
Expand Down
Loading