Merge pull request #363 from DanTheMan827/video-metadata

Add metadata support for video downloads
This commit is contained in:
edidealt 2026-03-20 20:25:07 +02:00 committed by GitHub
commit 9071670ad9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 100 additions and 80 deletions

View file

@ -20,8 +20,9 @@ export class TidalResponse extends Response {
export class HiFiClient { export class HiFiClient {
private static token: string | null; private static token: string | null;
private countryCode: string;
private static appTokenExpiry = 0; private static appTokenExpiry = 0;
private static tokenPromise: Promise<string> | null = null;
private countryCode: string;
private static albumTracksMax = 20; private static albumTracksMax = 20;
private static albumTracksActive = 0; private static albumTracksActive = 0;
private static albumTracksQueue: Array<() => void> = []; private static albumTracksQueue: Array<() => void> = [];
@ -35,7 +36,7 @@ export class HiFiClient {
return u.toString(); return u.toString();
} }
private encodeBasic(id: string, secret: string) { private static encodeBasic(id: string, secret: string) {
if (typeof window !== 'undefined' && typeof window.btoa === 'function') { if (typeof window !== 'undefined' && typeof window.btoa === 'function') {
return window.btoa(`${id}:${secret}`); return window.btoa(`${id}:${secret}`);
} }
@ -43,10 +44,15 @@ export class HiFiClient {
return Buffer.from(`${id}:${secret}`).toString('base64'); return Buffer.from(`${id}:${secret}`).toString('base64');
} }
private async fetchAppToken(signal: AbortSignal = new AbortController().signal): Promise<string> { private static async fetchAppToken(signal: AbortSignal = new AbortController().signal) {
const now = Date.now(); const now = Date.now();
HiFiClient.token ??= localStorage.getItem('hifi_token') || null;
HiFiClient.appTokenExpiry = Number(localStorage.getItem('hifi_token_expiry') || '0');
if (HiFiClient.token && now < HiFiClient.appTokenExpiry) return HiFiClient.token; if (HiFiClient.token && now < HiFiClient.appTokenExpiry) return HiFiClient.token;
return await (HiFiClient.tokenPromise ??= (async () => {
try {
const res = await fetch('https://auth.tidal.com/v1/oauth2/token', { const res = await fetch('https://auth.tidal.com/v1/oauth2/token', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -71,7 +77,14 @@ export class HiFiClient {
const expires_in = json.expires_in ?? 3600; const expires_in = json.expires_in ?? 3600;
HiFiClient.token = token; HiFiClient.token = token;
HiFiClient.appTokenExpiry = Date.now() + expires_in * 1000 - 60_000; HiFiClient.appTokenExpiry = Date.now() + expires_in * 1000 - 60_000;
localStorage.setItem('hifi_token', token);
localStorage.setItem('hifi_token_expiry', HiFiClient.appTokenExpiry.toString());
return token; return token;
} finally {
HiFiClient.tokenPromise = null;
}
})());
} }
constructor(countryCode = 'US') { constructor(countryCode = 'US') {
@ -81,7 +94,7 @@ export class HiFiClient {
private async fetchJson(url: string, params?: Params, signal: AbortSignal = new AbortController().signal) { private async fetchJson(url: string, params?: Params, signal: AbortSignal = new AbortController().signal) {
const final = HiFiClient.buildUrl(url, params); const final = HiFiClient.buildUrl(url, params);
const res = await fetch(final, { const res = await fetch(final, {
headers: { authorization: `Bearer ${await this.fetchAppToken(signal)}` }, headers: { authorization: `Bearer ${await HiFiClient.fetchAppToken(signal)}` },
signal, signal,
}); });

View file

@ -15,7 +15,7 @@ import { APICache } from './cache.js';
import { DashDownloader } from './dash-downloader.ts'; import { DashDownloader } from './dash-downloader.ts';
import { HlsDownloader } from './hls-downloader.js'; import { HlsDownloader } from './hls-downloader.js';
import { MP3EncodingError } from './mp3-encoder.js'; import { MP3EncodingError } from './mp3-encoder.js';
import { loadFfmpeg, FfmpegError } from './ffmpeg.js'; import { loadFfmpeg, FfmpegError, ffmpeg } from './ffmpeg.js';
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts'; import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
import { isCustomFormat } from './ffmpegFormats.ts'; import { isCustomFormat } from './ffmpegFormats.ts';
import { DownloadProgress } from './progressEvents.js'; import { DownloadProgress } from './progressEvents.js';
@ -56,11 +56,14 @@ export class LosslessAPI {
async fetchWithRetry(relativePath, options = {}) { async fetchWithRetry(relativePath, options = {}) {
const type = options.type || 'api'; const type = options.type || 'api';
const instanceRoutes = ['/track', '/album/similar', '/artist/similar', '/video']; const instanceRoutes = ['/track', '/album/similar', '/artist/similar', '/video', '/recommendations'];
if (window.allTidal == true || !instanceRoutes.some((route) => relativePath.startsWith(route))) { if (window.allTidal == true || !instanceRoutes.some((route) => relativePath.startsWith(route))) {
try { try {
if (import.meta.env.DEV) {
console.log(relativePath); console.log(relativePath);
}
return await client.queryResponse(relativePath); return await client.queryResponse(relativePath);
} catch (err) { } catch (err) {
console.warn( console.warn(
@ -1504,6 +1507,7 @@ export class LosslessAPI {
if (!isVideo) { if (!isVideo) {
blob = await applyAudioPostProcessing(blob, quality, onProgress, options.signal); blob = await applyAudioPostProcessing(blob, quality, onProgress, options.signal);
}
// Add metadata if track information is provided // Add metadata if track information is provided
if (track) { if (track) {
@ -1522,10 +1526,7 @@ export class LosslessAPI {
}; };
} }
if ( if (track.album?.id && (track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)) {
track.album?.id &&
(track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)
) {
try { try {
const albumData = await this.getAlbum(track.album.id); const albumData = await this.getAlbum(track.album.id);
if (albumData.tracks?.length > 0) { if (albumData.tracks?.length > 0) {
@ -1552,12 +1553,18 @@ export class LosslessAPI {
onProgress?.(new DownloadProgress('Adding metadata')); onProgress?.(new DownloadProgress('Adding metadata'));
try { try {
if (isVideo) {
blob = new File(
[await ffmpeg(blob, ['-c', 'copy'], 'output.mp4', 'video/mp4', onProgress, options.signal)],
'output.mp4',
{ type: 'video/mp4' }
);
}
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises); blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} }
}
if (options.triggerDownload ?? true) { if (options.triggerDownload ?? true) {
// Detect actual format and fix filename extension if needed // Detect actual format and fix filename extension if needed

View file

@ -34,12 +34,12 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
try { try {
data.title = getTrackTitle(track); data.title = getTrackTitle(track);
data.artist = getFullArtistString(track); data.artist = getFullArtistString(track);
data.albumTitle = track.album.title; data.albumTitle = track.album?.title;
data.albumArtist = track.album?.artist?.name || track.artist?.name; data.albumArtist = track.album?.artist?.name || track.artist?.name;
data.trackNumber = track.trackNumber; data.trackNumber = track.trackNumber;
data.discNumber = track.volumeNumber ?? track.discNumber; data.discNumber = track.volumeNumber ?? track.discNumber;
data.totalTracks = track.album.numberOfTracksOnDisc ?? track.album.numberOfTracks; data.totalTracks = track.album?.numberOfTracksOnDisc ?? track.album?.numberOfTracks;
data.totalDiscs = track.album.totalDiscs; data.totalDiscs = track.album?.totalDiscs;
data.copyright = track.copyright; data.copyright = track.copyright;
data.isrc = track.isrc; data.isrc = track.isrc;
data.explicit = Boolean(track.explicit); data.explicit = Boolean(track.explicit);