mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
Compare commits
4 Commits
df4967dd55
...
3d8df1eb48
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d8df1eb48 | |||
| 085f58e40f | |||
| 72bc53e495 | |||
| 651d87af4c |
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Track } from '$lib/types/track';
|
||||
import PageDecoration from '$lib/components/PageDecoration.svelte';
|
||||
import { deezerAuth } from '$lib/stores/deezer';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -10,6 +11,9 @@
|
||||
tracks: Track[];
|
||||
selectedTrackIndex?: number | null;
|
||||
onTrackClick?: (index: number) => void;
|
||||
onDownloadTrack?: (index: number) => void;
|
||||
onDownloadPlaylist?: () => void;
|
||||
downloadingTrackIds?: Set<string>;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -19,7 +23,10 @@
|
||||
coverImageUrl,
|
||||
tracks,
|
||||
selectedTrackIndex = null,
|
||||
onTrackClick
|
||||
onTrackClick,
|
||||
onDownloadTrack,
|
||||
onDownloadPlaylist,
|
||||
downloadingTrackIds = new Set()
|
||||
}: Props = $props();
|
||||
|
||||
type ViewMode = 'tracks' | 'info';
|
||||
@@ -30,6 +37,19 @@
|
||||
onTrackClick(index);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownloadClick(index: number, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
if (onDownloadTrack) {
|
||||
onDownloadTrack(index);
|
||||
}
|
||||
}
|
||||
|
||||
function isTrackDownloading(track: Track): boolean {
|
||||
const trackId = (track as any).spotifyId?.toString();
|
||||
if (!trackId) return false;
|
||||
return downloadingTrackIds.has(trackId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageDecoration label="SPOTIFY PLAYLIST" />
|
||||
@@ -85,6 +105,9 @@
|
||||
<th>Artist</th>
|
||||
<th>Album</th>
|
||||
<th>Duration</th>
|
||||
{#if $deezerAuth.loggedIn}
|
||||
<th style="width: 100px;">Actions</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -106,6 +129,17 @@
|
||||
—
|
||||
{/if}
|
||||
</td>
|
||||
{#if $deezerAuth.loggedIn}
|
||||
<td class="actions">
|
||||
<button
|
||||
onclick={(e) => handleDownloadClick(i, e)}
|
||||
disabled={isTrackDownloading(track)}
|
||||
class="download-btn"
|
||||
>
|
||||
{isTrackDownloading(track) ? 'Queued' : 'Download'}
|
||||
</button>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -131,6 +165,22 @@
|
||||
<span>{tracks.length}</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if $deezerAuth.loggedIn}
|
||||
<fieldset style="margin-top: 16px;">
|
||||
<legend>Actions</legend>
|
||||
<button onclick={onDownloadPlaylist}>
|
||||
Download Playlist
|
||||
</button>
|
||||
<p class="help-text">Download all tracks via Deezer and save as m3u8 playlist</p>
|
||||
</fieldset>
|
||||
{:else}
|
||||
<fieldset style="margin-top: 16px;">
|
||||
<legend>Downloads</legend>
|
||||
<p class="warning-text">Deezer login required to download Spotify tracks</p>
|
||||
<p class="help-text">Sign in to Deezer in Services → Deezer to enable downloads</p>
|
||||
</fieldset>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -250,4 +300,25 @@
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
margin: 8px 0 0 0;
|
||||
font-size: 11px;
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 12px;
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.actions {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -185,13 +185,13 @@ export class SpotifyAPI {
|
||||
this.expiresAt = Date.now() + (data.expires_in * 1000);
|
||||
|
||||
// Note: Spotify may or may not return a new refresh token
|
||||
const refreshToken = data.refresh_token || this.refreshToken;
|
||||
const refreshToken = data.refresh_token || this.refreshToken!;
|
||||
if (data.refresh_token) {
|
||||
this.refreshToken = data.refresh_token;
|
||||
}
|
||||
|
||||
// Save refreshed tokens to store
|
||||
await saveTokens(this.accessToken, refreshToken, data.expires_in);
|
||||
await saveTokens(this.accessToken!, refreshToken, data.expires_in);
|
||||
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
@@ -203,7 +203,7 @@ export class SpotifyAPI {
|
||||
* Make an authenticated API call to Spotify
|
||||
* Automatically refreshes token if expired
|
||||
*/
|
||||
private async apiCall<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
async apiCall<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
// Check if token needs refresh
|
||||
if (isTokenExpired(this.expiresAt)) {
|
||||
console.log('[Spotify] Token expired, refreshing...');
|
||||
|
||||
216
src/lib/services/spotify/addToQueue.ts
Normal file
216
src/lib/services/spotify/addToQueue.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Utility to add a Spotify track to the download queue by converting it to Deezer
|
||||
* Uses ISRC matching to find the equivalent Deezer track
|
||||
*/
|
||||
|
||||
import { deezerAPI } from '../deezer';
|
||||
import { addToQueue } from '$lib/stores/downloadQueue';
|
||||
import { settings } from '$lib/stores/settings';
|
||||
import { deezerAuth } from '$lib/stores/deezer';
|
||||
import { trackExists } from '../deezer/downloader';
|
||||
import { setInfo, setWarning, setError } from '$lib/stores/status';
|
||||
import { get } from 'svelte/store';
|
||||
import { convertSpotifyTrackToDeezer, type SpotifyTrackInput } from './converter';
|
||||
import type { DeezerTrack } from '$lib/types/deezer';
|
||||
|
||||
export interface SpotifyTrackData {
|
||||
id: string;
|
||||
name: string;
|
||||
artist_name: string;
|
||||
album_name: string;
|
||||
duration_ms: number;
|
||||
isrc?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Spotify track to the download queue by converting it to Deezer
|
||||
* @param spotifyTrack - Spotify track data (from cache or API)
|
||||
* @returns Result object with success status and details
|
||||
*/
|
||||
export async function addSpotifyTrackToQueue(
|
||||
spotifyTrack: SpotifyTrackData
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
deezerId?: string;
|
||||
matchMethod?: string;
|
||||
reason?: string;
|
||||
}> {
|
||||
// Ensure Deezer authentication
|
||||
const authState = get(deezerAuth);
|
||||
if (!authState.loggedIn || !authState.arl) {
|
||||
setError('Deezer login required for downloads');
|
||||
return {
|
||||
success: false,
|
||||
reason: 'deezer_auth_required'
|
||||
};
|
||||
}
|
||||
|
||||
deezerAPI.setArl(authState.arl);
|
||||
|
||||
try {
|
||||
// Convert Spotify track to Deezer
|
||||
console.log(`[AddSpotifyToQueue] Converting: ${spotifyTrack.name} by ${spotifyTrack.artist_name}`);
|
||||
|
||||
const conversionInput: SpotifyTrackInput = {
|
||||
id: spotifyTrack.id,
|
||||
name: spotifyTrack.name,
|
||||
artists: [spotifyTrack.artist_name],
|
||||
album: spotifyTrack.album_name,
|
||||
duration_ms: spotifyTrack.duration_ms,
|
||||
isrc: spotifyTrack.isrc
|
||||
};
|
||||
|
||||
const conversionResult = await convertSpotifyTrackToDeezer(conversionInput);
|
||||
|
||||
if (!conversionResult.success || !conversionResult.deezerTrack) {
|
||||
const errorMsg = `Could not find "${spotifyTrack.name}" on Deezer`;
|
||||
console.warn(`[AddSpotifyToQueue] ${errorMsg}`);
|
||||
setWarning(errorMsg);
|
||||
return {
|
||||
success: false,
|
||||
reason: conversionResult.error || 'conversion_failed'
|
||||
};
|
||||
}
|
||||
|
||||
const deezerPublicTrack = conversionResult.deezerTrack;
|
||||
const deezerTrackId = deezerPublicTrack.id.toString();
|
||||
|
||||
console.log(
|
||||
`[AddSpotifyToQueue] Matched to Deezer track: ${deezerTrackId} via ${conversionResult.matchMethod}`
|
||||
);
|
||||
|
||||
// Fetch full track data from Deezer GW API
|
||||
const deezerFullTrack = await deezerAPI.getTrack(deezerTrackId);
|
||||
|
||||
if (!deezerFullTrack || !deezerFullTrack.SNG_ID) {
|
||||
const errorMsg = 'Failed to fetch full Deezer track data';
|
||||
console.error(`[AddSpotifyToQueue] ${errorMsg}`);
|
||||
setError(errorMsg);
|
||||
return {
|
||||
success: false,
|
||||
reason: 'deezer_fetch_failed'
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch album data for cover art
|
||||
let albumData = null;
|
||||
try {
|
||||
albumData = await deezerAPI.getAlbumData(deezerFullTrack.ALB_ID.toString());
|
||||
} catch (error) {
|
||||
console.warn('[AddSpotifyToQueue] Could not fetch album data:', error);
|
||||
}
|
||||
|
||||
// Fetch lyrics
|
||||
let lyricsData = null;
|
||||
try {
|
||||
lyricsData = await deezerAPI.getLyrics(deezerFullTrack.SNG_ID.toString());
|
||||
} catch (error) {
|
||||
console.warn('[AddSpotifyToQueue] Could not fetch lyrics:', error);
|
||||
}
|
||||
|
||||
// Parse lyrics if available
|
||||
let lyrics = undefined;
|
||||
if (lyricsData) {
|
||||
let syncLrc = '';
|
||||
if (lyricsData.LYRICS_SYNC_JSON) {
|
||||
for (const line of lyricsData.LYRICS_SYNC_JSON) {
|
||||
const text = line.line || '';
|
||||
const timestamp = line.lrc_timestamp || '[00:00.00]';
|
||||
syncLrc += `${timestamp}${text}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
lyrics = {
|
||||
sync: syncLrc || undefined,
|
||||
unsync: lyricsData.LYRICS_TEXT || undefined,
|
||||
syncID3: undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Build full DeezerTrack object
|
||||
const deezerTrack: DeezerTrack = {
|
||||
id: parseInt(deezerFullTrack.SNG_ID, 10),
|
||||
title: deezerFullTrack.SNG_TITLE,
|
||||
artist: deezerFullTrack.ART_NAME,
|
||||
artistId: parseInt(deezerFullTrack.ART_ID, 10),
|
||||
artists: [deezerFullTrack.ART_NAME],
|
||||
album: deezerFullTrack.ALB_TITLE,
|
||||
albumId: parseInt(deezerFullTrack.ALB_ID, 10),
|
||||
albumArtist: deezerFullTrack.ART_NAME,
|
||||
albumArtistId: parseInt(deezerFullTrack.ART_ID, 10),
|
||||
trackNumber:
|
||||
typeof deezerFullTrack.TRACK_NUMBER === 'number'
|
||||
? deezerFullTrack.TRACK_NUMBER
|
||||
: parseInt(deezerFullTrack.TRACK_NUMBER, 10),
|
||||
discNumber:
|
||||
typeof deezerFullTrack.DISK_NUMBER === 'number'
|
||||
? deezerFullTrack.DISK_NUMBER
|
||||
: parseInt(deezerFullTrack.DISK_NUMBER, 10),
|
||||
duration:
|
||||
typeof deezerFullTrack.DURATION === 'number'
|
||||
? deezerFullTrack.DURATION
|
||||
: parseInt(deezerFullTrack.DURATION, 10),
|
||||
explicit: deezerFullTrack.EXPLICIT_LYRICS === 1,
|
||||
md5Origin: deezerFullTrack.MD5_ORIGIN,
|
||||
mediaVersion: deezerFullTrack.MEDIA_VERSION,
|
||||
trackToken: deezerFullTrack.TRACK_TOKEN,
|
||||
// Enhanced metadata
|
||||
lyrics,
|
||||
albumCoverUrl: albumData?.ALB_PICTURE
|
||||
? `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/500x500-000000-80-0-0.jpg`
|
||||
: undefined,
|
||||
albumCoverXlUrl: albumData?.ALB_PICTURE
|
||||
? `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/1000x1000-000000-80-0-0.jpg`
|
||||
: undefined,
|
||||
label: albumData?.LABEL_NAME,
|
||||
barcode: albumData?.UPC,
|
||||
releaseDate: deezerFullTrack.PHYSICAL_RELEASE_DATE,
|
||||
genre: deezerFullTrack.GENRE ? [deezerFullTrack.GENRE] : undefined,
|
||||
copyright: deezerFullTrack.COPYRIGHT
|
||||
};
|
||||
|
||||
// Check if we should skip this track (if it exists and overwrite is false)
|
||||
const appSettings = get(settings);
|
||||
|
||||
if (!appSettings.deezerOverwrite && appSettings.musicFolder) {
|
||||
const exists = await trackExists(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat);
|
||||
|
||||
if (exists) {
|
||||
console.log(`[AddSpotifyToQueue] Skipping "${deezerTrack.title}" - already exists`);
|
||||
setWarning(`Skipped: ${deezerTrack.title} (already exists)`);
|
||||
return {
|
||||
success: false,
|
||||
deezerId: deezerTrackId,
|
||||
matchMethod: conversionResult.matchMethod,
|
||||
reason: 'already_exists'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Add to queue
|
||||
await addToQueue({
|
||||
source: 'deezer',
|
||||
type: 'track',
|
||||
title: deezerTrack.title,
|
||||
artist: deezerTrack.artist,
|
||||
totalTracks: 1,
|
||||
downloadObject: deezerTrack
|
||||
});
|
||||
|
||||
setInfo(`Queued: ${deezerTrack.title}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deezerId: deezerTrackId,
|
||||
matchMethod: conversionResult.matchMethod
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = `Error adding track to queue: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
console.error('[AddSpotifyToQueue]', errorMsg);
|
||||
setError(errorMsg);
|
||||
return {
|
||||
success: false,
|
||||
reason: 'queue_error'
|
||||
};
|
||||
}
|
||||
}
|
||||
215
src/lib/services/spotify/converter.ts
Normal file
215
src/lib/services/spotify/converter.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Spotify to Deezer track conversion utilities
|
||||
* Matches Spotify tracks to Deezer tracks using ISRC codes (primary) or metadata search (fallback)
|
||||
*/
|
||||
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { deezerAPI } from '../deezer';
|
||||
|
||||
export interface SpotifyTrackInput {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: string[];
|
||||
album: string;
|
||||
duration_ms: number;
|
||||
isrc?: string | null;
|
||||
}
|
||||
|
||||
export interface DeezerMatchResult {
|
||||
success: boolean;
|
||||
deezerTrack?: any;
|
||||
matchMethod?: 'isrc' | 'metadata' | 'none';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Deezer for a track by ISRC code
|
||||
* This is the primary and most reliable matching method
|
||||
*/
|
||||
export async function searchDeezerByISRC(isrc: string): Promise<any | null> {
|
||||
if (!isrc || isrc.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[Converter] Searching Deezer by ISRC: ${isrc}`);
|
||||
|
||||
const url = `https://api.deezer.com/2.0/track/isrc:${encodeURIComponent(isrc)}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
connectTimeout: 30000
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`[Converter] ISRC search failed with status: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Check if we got an error response
|
||||
if (result.error) {
|
||||
console.warn(`[Converter] ISRC search returned error:`, result.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Valid track found
|
||||
if (result.id) {
|
||||
console.log(`[Converter] Found Deezer track by ISRC: ${result.id} - ${result.title}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[Converter] Error searching by ISRC:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Deezer for a track by metadata (title + artist)
|
||||
* Used as fallback when ISRC is not available or doesn't match
|
||||
*/
|
||||
export async function searchDeezerByMetadata(
|
||||
title: string,
|
||||
artist: string,
|
||||
durationMs?: number
|
||||
): Promise<any | null> {
|
||||
try {
|
||||
// Build search query: "artist title"
|
||||
const query = `${artist} ${title}`.trim();
|
||||
console.log(`[Converter] Searching Deezer by metadata: "${query}"`);
|
||||
|
||||
const searchResults = await deezerAPI.searchTracks(query, 10);
|
||||
|
||||
if (!searchResults.data || searchResults.data.length === 0) {
|
||||
console.warn(`[Converter] No results found for: "${query}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to find best match
|
||||
// Priority: exact title match, then duration match (±2 seconds)
|
||||
const durationSec = durationMs ? Math.floor(durationMs / 1000) : undefined;
|
||||
|
||||
for (const track of searchResults.data) {
|
||||
// Check title similarity (case-insensitive)
|
||||
const titleMatch = track.title.toLowerCase() === title.toLowerCase();
|
||||
|
||||
// Check duration if available (within 2 seconds tolerance)
|
||||
const durationMatch = !durationSec || Math.abs(track.duration - durationSec) <= 2;
|
||||
|
||||
if (titleMatch && durationMatch) {
|
||||
console.log(`[Converter] Found exact match by metadata: ${track.id} - ${track.title}`);
|
||||
return track;
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match, return first result as best guess
|
||||
const firstResult = searchResults.data[0];
|
||||
console.log(`[Converter] Using first result as best match: ${firstResult.id} - ${firstResult.title}`);
|
||||
return firstResult;
|
||||
} catch (error) {
|
||||
console.error('[Converter] Error searching by metadata:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Spotify track to Deezer track ID
|
||||
* Uses ISRC matching first, falls back to metadata search
|
||||
*/
|
||||
export async function convertSpotifyTrackToDeezer(
|
||||
spotifyTrack: SpotifyTrackInput
|
||||
): Promise<DeezerMatchResult> {
|
||||
console.log(`[Converter] Converting Spotify track: ${spotifyTrack.name} by ${spotifyTrack.artists.join(', ')}`);
|
||||
|
||||
// Try ISRC matching first (most reliable)
|
||||
if (spotifyTrack.isrc) {
|
||||
const deezerTrack = await searchDeezerByISRC(spotifyTrack.isrc);
|
||||
if (deezerTrack) {
|
||||
return {
|
||||
success: true,
|
||||
deezerTrack,
|
||||
matchMethod: 'isrc'
|
||||
};
|
||||
}
|
||||
console.log(`[Converter] ISRC match failed for: ${spotifyTrack.isrc}`);
|
||||
}
|
||||
|
||||
// Fallback to metadata search
|
||||
const artist = spotifyTrack.artists[0] || 'Unknown';
|
||||
const deezerTrack = await searchDeezerByMetadata(
|
||||
spotifyTrack.name,
|
||||
artist,
|
||||
spotifyTrack.duration_ms
|
||||
);
|
||||
|
||||
if (deezerTrack) {
|
||||
return {
|
||||
success: true,
|
||||
deezerTrack,
|
||||
matchMethod: 'metadata'
|
||||
};
|
||||
}
|
||||
|
||||
// No match found
|
||||
console.warn(`[Converter] Could not find Deezer match for: ${spotifyTrack.name} by ${artist}`);
|
||||
return {
|
||||
success: false,
|
||||
matchMethod: 'none',
|
||||
error: 'No match found on Deezer'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert multiple Spotify tracks to Deezer track IDs
|
||||
* Returns both successful conversions and failed tracks
|
||||
*/
|
||||
export async function convertSpotifyTracksBatch(
|
||||
spotifyTracks: SpotifyTrackInput[]
|
||||
): Promise<{
|
||||
conversions: Array<{ spotifyId: string; deezerId: string; matchMethod: string }>;
|
||||
failures: Array<{ spotifyId: string; name: string; artist: string; error: string }>;
|
||||
}> {
|
||||
const conversions: Array<{ spotifyId: string; deezerId: string; matchMethod: string }> = [];
|
||||
const failures: Array<{ spotifyId: string; name: string; artist: string; error: string }> = [];
|
||||
|
||||
console.log(`[Converter] Converting ${spotifyTracks.length} Spotify tracks to Deezer...`);
|
||||
|
||||
for (const track of spotifyTracks) {
|
||||
try {
|
||||
const result = await convertSpotifyTrackToDeezer(track);
|
||||
|
||||
if (result.success && result.deezerTrack) {
|
||||
conversions.push({
|
||||
spotifyId: track.id,
|
||||
deezerId: result.deezerTrack.id.toString(),
|
||||
matchMethod: result.matchMethod || 'unknown'
|
||||
});
|
||||
} else {
|
||||
failures.push({
|
||||
spotifyId: track.id,
|
||||
name: track.name,
|
||||
artist: track.artists[0] || 'Unknown',
|
||||
error: result.error || 'Unknown error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Converter] Error converting track ${track.name}:`, error);
|
||||
failures.push({
|
||||
spotifyId: track.id,
|
||||
name: track.name,
|
||||
artist: track.artists[0] || 'Unknown',
|
||||
error: error instanceof Error ? error.message : 'Conversion error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Converter] Conversion complete: ${conversions.length} successful, ${failures.length} failed`);
|
||||
|
||||
return { conversions, failures };
|
||||
}
|
||||
233
src/lib/services/spotify/playlistDownloader.ts
Normal file
233
src/lib/services/spotify/playlistDownloader.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Download Spotify playlist - converts tracks to Deezer via ISRC, adds to queue, and creates m3u8 file
|
||||
*/
|
||||
|
||||
import { addToQueue } from '$lib/stores/downloadQueue';
|
||||
import { trackExists } from '$lib/services/deezer/downloader';
|
||||
import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8';
|
||||
import { generateTrackPath } from '$lib/services/deezer/paths';
|
||||
import { settings } from '$lib/stores/settings';
|
||||
import { deezerAuth } from '$lib/stores/deezer';
|
||||
import { deezerAPI } from '$lib/services/deezer';
|
||||
import { setInfo, setSuccess, setWarning } from '$lib/stores/status';
|
||||
import { get } from 'svelte/store';
|
||||
import { mkdir } from '@tauri-apps/plugin-fs';
|
||||
import { convertSpotifyTrackToDeezer, type SpotifyTrackInput } from './converter';
|
||||
import type { DeezerTrack } from '$lib/types/deezer';
|
||||
|
||||
export interface SpotifyPlaylistTrack {
|
||||
id: number | string;
|
||||
track_id: string;
|
||||
name: string;
|
||||
artist_name: string;
|
||||
album_name: string;
|
||||
duration_ms: number;
|
||||
isrc?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a Spotify playlist by converting tracks to Deezer equivalents
|
||||
* - Converts all tracks via ISRC matching
|
||||
* - Adds converted tracks to the download queue (respects overwrite setting)
|
||||
* - Creates an m3u8 playlist file with relative paths
|
||||
*
|
||||
* @param playlistName - Name of the playlist
|
||||
* @param spotifyTracks - Array of Spotify track objects
|
||||
* @param playlistsFolder - Path to playlists folder
|
||||
* @param musicFolder - Path to music folder
|
||||
* @returns Object with m3u8 path and statistics
|
||||
*/
|
||||
export async function downloadSpotifyPlaylist(
|
||||
playlistName: string,
|
||||
spotifyTracks: SpotifyPlaylistTrack[],
|
||||
playlistsFolder: string,
|
||||
musicFolder: string
|
||||
): Promise<{
|
||||
m3u8Path: string;
|
||||
stats: {
|
||||
total: number;
|
||||
queued: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
};
|
||||
}> {
|
||||
const appSettings = get(settings);
|
||||
const authState = get(deezerAuth);
|
||||
|
||||
// Ensure Deezer is authenticated
|
||||
if (!authState.loggedIn || !authState.arl) {
|
||||
throw new Error('Deezer authentication required for downloads');
|
||||
}
|
||||
|
||||
deezerAPI.setArl(authState.arl);
|
||||
|
||||
console.log(`[SpotifyPlaylistDownloader] Starting download for playlist: ${playlistName}`);
|
||||
console.log(`[SpotifyPlaylistDownloader] Tracks: ${spotifyTracks.length}`);
|
||||
|
||||
// Ensure playlists folder exists
|
||||
try {
|
||||
await mkdir(playlistsFolder, { recursive: true });
|
||||
} catch (error) {
|
||||
// Folder might already exist
|
||||
}
|
||||
|
||||
// Track statistics
|
||||
let queuedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// Track successful conversions for m3u8 generation
|
||||
const successfulTracks: Array<{
|
||||
deezerTrack: DeezerTrack;
|
||||
spotifyTrack: SpotifyPlaylistTrack;
|
||||
}> = [];
|
||||
|
||||
// Convert and queue each track
|
||||
for (const spotifyTrack of spotifyTracks) {
|
||||
try {
|
||||
// Convert Spotify track to Deezer
|
||||
const conversionInput: SpotifyTrackInput = {
|
||||
id: spotifyTrack.track_id,
|
||||
name: spotifyTrack.name,
|
||||
artists: [spotifyTrack.artist_name],
|
||||
album: spotifyTrack.album_name,
|
||||
duration_ms: spotifyTrack.duration_ms,
|
||||
isrc: spotifyTrack.isrc
|
||||
};
|
||||
|
||||
const conversionResult = await convertSpotifyTrackToDeezer(conversionInput);
|
||||
|
||||
if (!conversionResult.success || !conversionResult.deezerTrack) {
|
||||
console.warn(
|
||||
`[SpotifyPlaylistDownloader] Failed to convert: ${spotifyTrack.name} - ${conversionResult.error}`
|
||||
);
|
||||
failedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const deezerPublicTrack = conversionResult.deezerTrack;
|
||||
|
||||
// Fetch full track data from Deezer GW API (needed for download)
|
||||
const deezerTrackId = deezerPublicTrack.id.toString();
|
||||
const deezerFullTrack = await deezerAPI.getTrack(deezerTrackId);
|
||||
|
||||
if (!deezerFullTrack || !deezerFullTrack.SNG_ID) {
|
||||
console.warn(`[SpotifyPlaylistDownloader] Could not fetch full Deezer track data for ID: ${deezerTrackId}`);
|
||||
failedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build DeezerTrack object
|
||||
const deezerTrack: DeezerTrack = {
|
||||
id: parseInt(deezerFullTrack.SNG_ID, 10),
|
||||
title: deezerFullTrack.SNG_TITLE,
|
||||
artist: deezerFullTrack.ART_NAME,
|
||||
artistId: parseInt(deezerFullTrack.ART_ID, 10),
|
||||
artists: [deezerFullTrack.ART_NAME],
|
||||
album: deezerFullTrack.ALB_TITLE,
|
||||
albumId: parseInt(deezerFullTrack.ALB_ID, 10),
|
||||
albumArtist: deezerFullTrack.ART_NAME,
|
||||
albumArtistId: parseInt(deezerFullTrack.ART_ID, 10),
|
||||
trackNumber:
|
||||
typeof deezerFullTrack.TRACK_NUMBER === 'number'
|
||||
? deezerFullTrack.TRACK_NUMBER
|
||||
: parseInt(deezerFullTrack.TRACK_NUMBER, 10),
|
||||
discNumber:
|
||||
typeof deezerFullTrack.DISK_NUMBER === 'number'
|
||||
? deezerFullTrack.DISK_NUMBER
|
||||
: parseInt(deezerFullTrack.DISK_NUMBER, 10),
|
||||
duration:
|
||||
typeof deezerFullTrack.DURATION === 'number'
|
||||
? deezerFullTrack.DURATION
|
||||
: parseInt(deezerFullTrack.DURATION, 10),
|
||||
explicit: deezerFullTrack.EXPLICIT_LYRICS === 1,
|
||||
md5Origin: deezerFullTrack.MD5_ORIGIN,
|
||||
mediaVersion: deezerFullTrack.MEDIA_VERSION,
|
||||
trackToken: deezerFullTrack.TRACK_TOKEN
|
||||
};
|
||||
|
||||
// Check if track already exists (if overwrite is disabled)
|
||||
if (!appSettings.deezerOverwrite && appSettings.musicFolder) {
|
||||
const exists = await trackExists(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat);
|
||||
if (exists) {
|
||||
console.log(`[SpotifyPlaylistDownloader] Skipping "${deezerTrack.title}" - already exists`);
|
||||
skippedCount++;
|
||||
// Still add to successful tracks for m3u8 generation
|
||||
successfulTracks.push({ deezerTrack, spotifyTrack });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Queue track for download
|
||||
await addToQueue({
|
||||
source: 'deezer',
|
||||
type: 'track',
|
||||
title: deezerTrack.title,
|
||||
artist: deezerTrack.artist,
|
||||
totalTracks: 1,
|
||||
downloadObject: deezerTrack
|
||||
});
|
||||
|
||||
queuedCount++;
|
||||
successfulTracks.push({ deezerTrack, spotifyTrack });
|
||||
|
||||
console.log(
|
||||
`[SpotifyPlaylistDownloader] Queued: ${deezerTrack.title} (matched via ${conversionResult.matchMethod})`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[SpotifyPlaylistDownloader] Error processing track ${spotifyTrack.name}:`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SpotifyPlaylistDownloader] Queued ${queuedCount} tracks, skipped ${skippedCount}, failed ${failedCount}`
|
||||
);
|
||||
|
||||
// Show queue status message
|
||||
if (queuedCount > 0) {
|
||||
const parts = [`Queued ${queuedCount} track${queuedCount !== 1 ? 's' : ''}`];
|
||||
if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
|
||||
if (failedCount > 0) parts.push(`${failedCount} not found`);
|
||||
setInfo(parts.join(', '));
|
||||
} else if (skippedCount > 0) {
|
||||
setWarning(`All ${skippedCount} tracks already exist`);
|
||||
} else if (failedCount > 0) {
|
||||
setWarning(`Could not find ${failedCount} tracks on Deezer`);
|
||||
}
|
||||
|
||||
// Generate m3u8 file using Deezer track paths
|
||||
const m3u8Tracks: M3U8Track[] = successfulTracks.map(({ deezerTrack, spotifyTrack }) => {
|
||||
// Generate expected path for this Deezer track
|
||||
const paths = generateTrackPath(deezerTrack, musicFolder, appSettings.deezerFormat, false);
|
||||
const absolutePath = `${paths.filepath}/${paths.filename}`;
|
||||
|
||||
// Convert to relative path from playlists folder
|
||||
const relativePath = makeRelativePath(absolutePath, 'Music');
|
||||
|
||||
return {
|
||||
duration: deezerTrack.duration,
|
||||
artist: deezerTrack.artist,
|
||||
title: deezerTrack.title,
|
||||
path: relativePath
|
||||
};
|
||||
});
|
||||
|
||||
// Write m3u8 file
|
||||
const m3u8Path = await writeM3U8(playlistName, m3u8Tracks, playlistsFolder);
|
||||
|
||||
console.log(`[SpotifyPlaylistDownloader] Playlist saved to: ${m3u8Path}`);
|
||||
|
||||
// Show success message for playlist creation
|
||||
setSuccess(`Playlist created: ${playlistName} (${successfulTracks.length} tracks)`);
|
||||
|
||||
return {
|
||||
m3u8Path,
|
||||
stats: {
|
||||
total: spotifyTracks.length,
|
||||
queued: queuedCount,
|
||||
skipped: skippedCount,
|
||||
failed: failedCount
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,11 @@
|
||||
} from '$lib/library/spotify-database';
|
||||
import SpotifyCollectionView from '$lib/components/SpotifyCollectionView.svelte';
|
||||
import type { Track, AudioFormat } from '$lib/types/track';
|
||||
import { addSpotifyTrackToQueue } from '$lib/services/spotify/addToQueue';
|
||||
import { downloadSpotifyPlaylist } from '$lib/services/spotify/playlistDownloader';
|
||||
import { settings } from '$lib/stores/settings';
|
||||
import { deezerAuth } from '$lib/stores/deezer';
|
||||
import { setError } from '$lib/stores/status';
|
||||
|
||||
let playlistId = $derived($page.params.id!);
|
||||
let loading = $state(true);
|
||||
@@ -22,6 +27,7 @@
|
||||
let playlistTracks = $state<SpotifyPlaylistTrack[]>([]);
|
||||
let selectedTrackIndex = $state<number | null>(null);
|
||||
let coverImageUrl = $state<string | undefined>(undefined);
|
||||
let downloadingTrackIds = $state(new Set<string>());
|
||||
|
||||
// Convert Spotify tracks to Track type for CollectionView
|
||||
let tracks = $derived<Track[]>(
|
||||
@@ -29,6 +35,7 @@
|
||||
path: '',
|
||||
filename: '',
|
||||
format: 'unknown' as AudioFormat,
|
||||
spotifyId: track.track_id, // Store Spotify ID for downloading
|
||||
metadata: {
|
||||
title: track.name || 'Unknown Title',
|
||||
artist: track.artist_name || 'Unknown Artist',
|
||||
@@ -200,6 +207,63 @@
|
||||
function handleTrackClick(index: number) {
|
||||
selectedTrackIndex = index;
|
||||
}
|
||||
|
||||
async function handleDownloadTrack(index: number) {
|
||||
if (!$deezerAuth.loggedIn) {
|
||||
setError('Deezer login required for downloads');
|
||||
return;
|
||||
}
|
||||
|
||||
const spotifyTrack = playlistTracks[index];
|
||||
if (!spotifyTrack) return;
|
||||
|
||||
// Mark as downloading
|
||||
downloadingTrackIds = new Set(downloadingTrackIds).add(spotifyTrack.track_id);
|
||||
|
||||
try {
|
||||
await addSpotifyTrackToQueue({
|
||||
id: spotifyTrack.track_id,
|
||||
name: spotifyTrack.name,
|
||||
artist_name: spotifyTrack.artist_name,
|
||||
album_name: spotifyTrack.album_name,
|
||||
duration_ms: spotifyTrack.duration_ms,
|
||||
isrc: spotifyTrack.isrc
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error downloading track:', error);
|
||||
} finally {
|
||||
// Remove from downloading set
|
||||
const newSet = new Set(downloadingTrackIds);
|
||||
newSet.delete(spotifyTrack.track_id);
|
||||
downloadingTrackIds = newSet;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadPlaylist() {
|
||||
if (!$deezerAuth.loggedIn) {
|
||||
setError('Deezer login required for downloads');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!playlist || !$settings.musicFolder || !$settings.playlistsFolder) {
|
||||
setError('Please configure music and playlists folders in settings');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadSpotifyPlaylist(
|
||||
playlist.name,
|
||||
playlistTracks,
|
||||
$settings.playlistsFolder,
|
||||
$settings.musicFolder
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error downloading playlist:', error);
|
||||
setError(
|
||||
'Error downloading playlist: ' + (error instanceof Error ? error.message : String(error))
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -226,7 +290,10 @@
|
||||
{coverImageUrl}
|
||||
{tracks}
|
||||
{selectedTrackIndex}
|
||||
{downloadingTrackIds}
|
||||
onTrackClick={handleTrackClick}
|
||||
onDownloadTrack={handleDownloadTrack}
|
||||
onDownloadPlaylist={handleDownloadPlaylist}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -365,7 +365,7 @@
|
||||
<fieldset>
|
||||
<legend>Device Paths</legend>
|
||||
<div class="field-row-stacked">
|
||||
<label>Device Music Path</label>
|
||||
<div class="field-label">Device Music Path</div>
|
||||
<div class="path-display">
|
||||
<code>{$deviceSyncSettings.musicPath || 'Not set'}</code>
|
||||
<button onclick={handleUpdateMusicPath}>Change...</button>
|
||||
@@ -373,7 +373,7 @@
|
||||
</div>
|
||||
|
||||
<div class="field-row-stacked">
|
||||
<label>Device Playlists Path</label>
|
||||
<div class="field-label">Device Playlists Path</div>
|
||||
<div class="path-display">
|
||||
<code>{$deviceSyncSettings.playlistsPath || 'Not set'}</code>
|
||||
<button onclick={handleUpdatePlaylistsPath}>Change...</button>
|
||||
|
||||
Reference in New Issue
Block a user