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))
);
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 || {}),
},
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');
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 };
}

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) => {
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);
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;
}
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