Compare commits

...

5 Commits

Author SHA1 Message Date
Markury
3d7d3ded1c feat: re-encode cover images for rockbox compatibility 2026-06-03 20:39:20 -04:00
Markury
2beae8e327 deps: image crate 2026-06-03 20:39:04 -04:00
Markury
cc92640908 fix: m3u8 relative path generation/resolution 2026-03-19 11:37:27 -04:00
Markury
e5d12c9041 feat: add refresh button to collection views 2026-03-18 11:08:23 -04:00
Markury
2c471370e4 feat(spotify): caching for Spotify to Deezer conversions to reduce API calls 2026-03-18 11:08:08 -04:00
15 changed files with 459 additions and 138 deletions

74
src-tauri/Cargo.lock generated
View File

@@ -10,6 +10,7 @@ dependencies = [
"byteorder",
"futures-util",
"id3",
"image",
"md5",
"metaflac",
"reqwest",
@@ -424,6 +425,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.10.1"
@@ -1949,7 +1956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
dependencies = [
"byteorder",
"png",
"png 0.17.16",
]
[[package]]
@@ -2076,6 +2083,21 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2491,6 +2513,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -2506,7 +2538,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-foundation 0.3.1",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.17",
"windows-sys 0.60.2",
@@ -3280,6 +3312,19 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.9.4",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -3408,6 +3453,12 @@ dependencies = [
"psl-types",
]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quick-xml"
version = "0.37.5"
@@ -4821,7 +4872,7 @@ dependencies = [
"ico",
"json-patch",
"plist",
"png",
"png 0.17.16",
"proc-macro2",
"quote",
"semver",
@@ -5516,7 +5567,7 @@ dependencies = [
"objc2-core-graphics",
"objc2-foundation 0.3.1",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.17",
"windows-sys 0.59.0",
@@ -6818,6 +6869,21 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.7.0"

View File

@@ -40,4 +40,5 @@ tauri-plugin-os = "2"
walkdir = "2.5.0"
unicode-normalization = "0.1.24"
tauri-plugin-oauth = "2.0.0"
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png"] }

View File

@@ -37,6 +37,14 @@ fn read_audio_metadata(path: String) -> Result<metadata::AudioMetadata, String>
metadata::read_audio_metadata(&path)
}
/// Re-encode cover image as baseline JPEG for broad player compatibility
#[tauri::command]
async fn reencode_cover_image(data: Vec<u8>, quality: u8) -> Result<Vec<u8>, String> {
tauri::async_runtime::spawn_blocking(move || tagger::reencode_cover_image(&data, quality))
.await
.map_err(|e| format!("Re-encode task failed: {}", e))?
}
/// Decrypt Deezer track data (legacy - kept for backwards compatibility)
#[tauri::command]
async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>, String> {
@@ -299,10 +307,11 @@ pub fn run() {
kind: MigrationKind::Up,
}];
let spotify_migrations = vec![Migration {
version: 1,
description: "create_spotify_cache_tables",
sql: "
let spotify_migrations = vec![
Migration {
version: 1,
description: "create_spotify_cache_tables",
sql: "
CREATE TABLE IF NOT EXISTS spotify_playlists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -364,8 +373,25 @@ pub fn run() {
CREATE INDEX IF NOT EXISTS idx_spotify_playlist_tracks_track ON spotify_playlist_tracks(track_id);
CREATE INDEX IF NOT EXISTS idx_spotify_playlist_tracks_isrc ON spotify_playlist_tracks(isrc);
",
kind: MigrationKind::Up,
}];
kind: MigrationKind::Up,
},
Migration {
version: 2,
description: "create_spotify_deezer_conversion_cache",
sql: "
CREATE TABLE IF NOT EXISTS spotify_deezer_conversions (
spotify_track_id TEXT PRIMARY KEY,
deezer_track_id INTEGER NOT NULL,
deezer_track_json TEXT NOT NULL,
match_method TEXT NOT NULL,
cached_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conversions_deezer_id ON spotify_deezer_conversions(deezer_track_id);
",
kind: MigrationKind::Up,
},
];
tauri::Builder::default()
.plugin(tauri_plugin_oauth::init())
@@ -387,6 +413,7 @@ pub fn run() {
greet,
tag_audio_file,
read_audio_metadata,
reencode_cover_image,
decrypt_deezer_track,
download_and_decrypt_track,
device_sync::index_and_compare,

View File

@@ -2,8 +2,10 @@ use id3::{
frame::{Picture, PictureType},
Tag as ID3Tag, TagLike, Version,
};
use image::codecs::jpeg::JpegEncoder;
use metaflac::Tag as FlacTag;
use serde::{Deserialize, Serialize};
use std::io::Cursor;
use std::path::Path;
/// Metadata structure for audio file tagging
@@ -356,3 +358,20 @@ fn detect_mime_type_str(data: &[u8]) -> &'static str {
"image/jpeg"
}
}
/// Re-encode image data as a baseline (non-progressive) JPEG in sRGB.
/// This ensures compatibility with players like Rockbox that can't
/// decode progressive JPEGs or non-sRGB colorspaces.
pub fn reencode_cover_image(data: &[u8], quality: u8) -> Result<Vec<u8>, String> {
let img = image::load_from_memory(data)
.map_err(|e| format!("Failed to decode image: {}", e))?;
let rgb = img.to_rgb8();
let mut buf = Cursor::new(Vec::new());
let encoder = JpegEncoder::new_with_quality(&mut buf, quality);
rgb.write_with_encoder(encoder)
.map_err(|e| format!("Failed to encode JPEG: {}", e))?;
Ok(buf.into_inner())
}

View File

@@ -13,6 +13,9 @@
onTrackClick?: (index: number) => void;
onDownloadTrack?: (index: number) => void;
onDownloadPlaylist?: () => void;
onRefresh?: () => void;
refreshing?: boolean;
lastCached?: number | null;
downloadingTrackIds?: Set<string>;
}
@@ -27,9 +30,18 @@
onTrackClick,
onDownloadTrack,
onDownloadPlaylist,
onRefresh,
refreshing = false,
lastCached = null,
downloadingTrackIds = new Set()
}: Props = $props();
function formatTimestamp(timestamp: number | null): string {
if (!timestamp) return 'Never';
const date = new Date(timestamp * 1000);
return date.toLocaleString();
}
type ViewMode = 'tracks' | 'info';
let viewMode = $state<ViewMode>('tracks');
@@ -177,14 +189,27 @@
<span class="field-label">Tracks:</span>
<span>{tracks.length}</span>
</div>
{#if lastCached}
<div class="field-row">
<span class="field-label">Last updated:</span>
<span>{formatTimestamp(lastCached)}</span>
</div>
{/if}
</fieldset>
<fieldset style="margin-top: 16px;">
<legend>Actions</legend>
<button onclick={onDownloadPlaylist}>
Download Playlist
</button>
<p class="help-text">Download all tracks and save as m3u8 playlist</p>
<div class="actions-row">
<div>
<button onclick={onDownloadPlaylist}>Download Playlist</button>
<p class="help-text">Download all tracks and save as m3u8 playlist</p>
</div>
{#if onRefresh}
<button onclick={onRefresh} disabled={refreshing}>
{refreshing ? 'Refreshing...' : 'Refresh Tracks'}
</button>
{/if}
</div>
</fieldset>
</div>
{/if}
@@ -300,6 +325,13 @@
text-align: center;
}
.actions-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.download-btn {
padding: 2px 8px;
font-size: 11px;

View File

@@ -13,6 +13,9 @@
onTrackClick?: (index: number) => void;
onDownloadTrack?: (index: number) => void;
onDownloadPlaylist?: () => void;
onRefresh?: () => void;
refreshing?: boolean;
lastCached?: number | null;
downloadingTrackIds?: Set<string>;
}
@@ -26,9 +29,18 @@
onTrackClick,
onDownloadTrack,
onDownloadPlaylist,
onRefresh,
refreshing = false,
lastCached = null,
downloadingTrackIds = new Set()
}: Props = $props();
function formatTimestamp(timestamp: number | null): string {
if (!timestamp) return 'Never';
const date = new Date(timestamp * 1000);
return date.toLocaleString();
}
type ViewMode = 'tracks' | 'info';
let viewMode = $state<ViewMode>('tracks');
@@ -164,20 +176,40 @@
<span class="field-label">Tracks:</span>
<span>{tracks.length}</span>
</div>
{#if lastCached}
<div class="field-row">
<span class="field-label">Last updated:</span>
<span>{formatTimestamp(lastCached)}</span>
</div>
{/if}
</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>
<div class="actions-row">
<div>
<button onclick={onDownloadPlaylist}>Download Playlist</button>
<p class="help-text">Download all tracks via Deezer and save as m3u8 playlist</p>
</div>
{#if onRefresh}
<button onclick={onRefresh} disabled={refreshing}>
{refreshing ? 'Refreshing...' : 'Refresh Tracks'}
</button>
{/if}
</div>
</fieldset>
{:else}
<fieldset style="margin-top: 16px;">
<legend>Downloads</legend>
<p class="warning-text">Deezer login required to download Spotify tracks</p>
<div class="actions-row">
<p class="warning-text">Deezer login required to download Spotify tracks</p>
{#if onRefresh}
<button onclick={onRefresh} disabled={refreshing}>
{refreshing ? 'Refreshing...' : 'Refresh Tracks'}
</button>
{/if}
</div>
<p class="help-text">Sign in to Deezer in Services → Deezer to enable downloads</p>
</fieldset>
{/if}
@@ -306,6 +338,13 @@
color: #808080;
}
.actions-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.warning-text {
margin: 0 0 8px 0;
font-size: 12px;

View File

@@ -57,35 +57,32 @@ export async function writeM3U8(
}
/**
* Convert absolute music file path to relative path from playlists folder
* Assumes music folder and playlists folder are siblings:
* Compute a relative path from the playlists folder to a music file.
* Music and playlists folders are expected to be siblings:
* /path/to/Music/Artist/Album/Track.flac
* /path/to/Playlists/playlist.m3u8
* Becomes: ../Music/Artist/Album/Track.flac
*
* @param absoluteMusicPath - Absolute path to music file
* @param musicFolderName - Name of music folder (default: 'Music')
* @param playlistsFolder - Absolute path to playlists folder
* @returns Relative path from playlists folder
*/
export function makeRelativePath(
absoluteMusicPath: string,
musicFolderName: string = 'Music'
playlistsFolder: string
): string {
// Split path into parts
const parts = absoluteMusicPath.split('/');
const fileParts = absoluteMusicPath.split('/').filter(Boolean);
const baseParts = playlistsFolder.replace(/\/$/, '').split('/').filter(Boolean);
// Find the music folder index
const musicIndex = parts.findIndex(part => part === musicFolderName);
if (musicIndex === -1) {
// Fallback: if music folder not found, use the path as-is
console.warn(`[M3U8] Could not find "${musicFolderName}" in path: ${absoluteMusicPath}`);
return absoluteMusicPath;
// Find common prefix length
let common = 0;
while (common < baseParts.length && common < fileParts.length && baseParts[common] === fileParts[common]) {
common++;
}
// Take everything from music folder onwards
const relativeParts = parts.slice(musicIndex);
// Go up from playlists folder to common ancestor, then down to the file
const ups = baseParts.length - common;
const remaining = fileParts.slice(common);
// Prepend ../ to go up from playlists folder
return `../${relativeParts.join('/')}`;
return [...Array(ups).fill('..'), ...remaining].join('/');
}

View File

@@ -277,23 +277,22 @@ export async function findPlaylistCoverFallback(
*/
export async function loadPlaylistTracks(
playlistPath: string,
playlistName: string,
baseFolder: string
playlistName: string
): Promise<PlaylistWithTracks> {
const parsedTracks = await parsePlaylist(playlistPath);
// Resolve relative paths against the playlist file's directory
const playlistDir = playlistPath.split('/').slice(0, -1).join('/');
// Load tracks with metadata in parallel
const tracks: Track[] = await Promise.all(
parsedTracks.map(async (parsedTrack) => {
const trackPath = parsedTrack.path;
// Handle relative paths - resolve relative to playlist location or music folder
// Resolve path: absolute paths used as-is, relative paths resolved from playlist dir
let fullPath = trackPath.startsWith('/') || trackPath.includes(':\\')
? trackPath // Absolute path
: `${baseFolder}/${trackPath}`; // Relative path
// Normalize path to remove .. and . segments for Tauri security
fullPath = normalizePath(fullPath);
? trackPath
: normalizePath(`${playlistDir}/${trackPath}`);
// Try to find the actual file (handles track number mismatches)
const actualPath = await findActualFilePath(fullPath);

View File

@@ -318,6 +318,63 @@ export async function upsertPlaylistTracks(playlistId: string, tracks: any[]): P
}
}
/**
* Cached Spotify→Deezer conversion result
*/
export interface CachedConversion {
spotify_track_id: string;
deezer_track_id: number;
deezer_track_json: string;
match_method: string;
cached_at: number;
}
/**
* Get cached Spotify→Deezer conversions for a batch of track IDs
*/
export async function getCachedConversions(
spotifyTrackIds: string[]
): Promise<Map<string, CachedConversion>> {
if (spotifyTrackIds.length === 0) return new Map();
const database = await initSpotifyDatabase();
const map = new Map<string, CachedConversion>();
// Query in batches of 100 to avoid SQLite variable limits
for (let i = 0; i < spotifyTrackIds.length; i += 100) {
const batch = spotifyTrackIds.slice(i, i + 100);
const placeholders = batch.map((_, idx) => `$${idx + 1}`).join(',');
const rows = await database.select<CachedConversion[]>(
`SELECT * FROM spotify_deezer_conversions WHERE spotify_track_id IN (${placeholders})`,
batch
);
for (const row of rows) {
map.set(row.spotify_track_id, row);
}
}
return map;
}
/**
* Cache a Spotify→Deezer conversion result
*/
export async function cacheConversion(
spotifyTrackId: string,
deezerTrackId: number,
deezerTrackJson: string,
matchMethod: string
): Promise<void> {
const database = await initSpotifyDatabase();
const now = Math.floor(Date.now() / 1000);
await database.execute(
`INSERT OR REPLACE INTO spotify_deezer_conversions (spotify_track_id, deezer_track_id, deezer_track_json, match_method, cached_at)
VALUES ($1, $2, $3, $4, $5)`,
[spotifyTrackId, deezerTrackId, deezerTrackJson, matchMethod, now]
);
}
/**
* Clear all Spotify cache
*/

View File

@@ -87,14 +87,20 @@ export async function downloadTrack(
// Get user settings
const appSettings = get(settings);
// Download cover art if enabled
// Download cover art if enabled, re-encode as baseline JPEG for player compatibility
let coverData: Uint8Array | undefined;
if ((appSettings.embedCoverArt || appSettings.saveCoverToFolder) && track.albumCoverUrl) {
try {
console.log('Downloading cover art...');
coverData = await downloadCover(track.albumCoverUrl);
const rawCover = await downloadCover(track.albumCoverUrl);
const reencoded = await invoke<number[]>('reencode_cover_image', {
data: Array.from(rawCover),
quality: appSettings.coverImageQuality
});
coverData = new Uint8Array(reencoded);
console.log(`[ImageDownload] Re-encoded cover: ${rawCover.length} -> ${coverData.length} bytes (q=${appSettings.coverImageQuality})`);
} catch (error) {
console.warn('Failed to download cover art:', error);
console.warn('Failed to download/re-encode cover art:', error);
}
}

View File

@@ -93,7 +93,7 @@ export async function downloadDeezerPlaylist(
const absolutePath = `${paths.filepath}/${paths.filename}`;
// Convert to relative path from playlists folder
const relativePath = makeRelativePath(absolutePath, 'Music');
const relativePath = makeRelativePath(absolutePath, playlistsFolder);
return {
duration: track.duration,

View File

@@ -1,5 +1,9 @@
/**
* Download Spotify playlist - converts tracks to Deezer via ISRC, adds to queue, and creates m3u8 file
*
* Uses a persistent SQLite cache for Spotify→Deezer conversions so that only
* new/uncached tracks require API calls. For a 200-track playlist where 3
* tracks were added, this reduces API calls from ~400 to ~6.
*/
import { addToQueue } from '$lib/stores/downloadQueue';
@@ -13,6 +17,7 @@ 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 { getCachedConversions, cacheConversion } from '$lib/library/spotify-database';
import type { DeezerTrack } from '$lib/types/deezer';
export interface SpotifyPlaylistTrack {
@@ -26,16 +31,8 @@ export interface SpotifyPlaylistTrack {
}
/**
* 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
* Download a Spotify playlist by converting tracks to Deezer equivalents.
* Cached conversions are reused — only uncached tracks hit the Deezer API.
*/
export async function downloadSpotifyPlaylist(
playlistName: string,
@@ -49,12 +46,12 @@ export async function downloadSpotifyPlaylist(
queued: number;
skipped: number;
failed: number;
cached: 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');
}
@@ -64,95 +61,122 @@ export async function downloadSpotifyPlaylist(
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
// --- Look up cached conversions in bulk ---
const spotifyIds = spotifyTracks.map((t) => t.track_id);
const cachedConversions = await getCachedConversions(spotifyIds);
const cachedCount = cachedConversions.size;
const uncachedCount = spotifyTracks.length - cachedCount;
console.log(
`[SpotifyPlaylistDownloader] Cache: ${cachedCount} cached, ${uncachedCount} need conversion`
);
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
};
let deezerTrack: DeezerTrack;
const conversionResult = await convertSpotifyTrackToDeezer(conversionInput);
// --- Check cache first ---
const cached = cachedConversions.get(spotifyTrack.track_id);
if (!conversionResult.success || !conversionResult.deezerTrack) {
console.warn(
`[SpotifyPlaylistDownloader] Failed to convert: ${spotifyTrack.name} - ${conversionResult.error}`
if (cached) {
// Use cached conversion — zero API calls
deezerTrack = JSON.parse(cached.deezer_track_json) as DeezerTrack;
} else {
// Convert via API (ISRC → metadata fallback)
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 GW API
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;
}
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
};
// Cache the conversion for future runs
await cacheConversion(
spotifyTrack.track_id,
deezerTrack.id,
JSON.stringify(deezerTrack),
conversionResult.matchMethod || 'unknown'
);
console.log(
`[SpotifyPlaylistDownloader] Converted & cached: ${deezerTrack.title} (via ${conversionResult.matchMethod})`
);
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)
// Check if track already exists locally
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;
}
@@ -170,10 +194,6 @@ export async function downloadSpotifyPlaylist(
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++;
@@ -181,7 +201,7 @@ export async function downloadSpotifyPlaylist(
}
console.log(
`[SpotifyPlaylistDownloader] Queued ${queuedCount} tracks, skipped ${skippedCount}, failed ${failedCount}`
`[SpotifyPlaylistDownloader] Queued ${queuedCount}, skipped ${skippedCount}, failed ${failedCount}, cached ${cachedCount}`
);
// Show queue status message
@@ -189,6 +209,7 @@ export async function downloadSpotifyPlaylist(
const parts = [`Queued ${queuedCount} track${queuedCount !== 1 ? 's' : ''}`];
if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
if (failedCount > 0) parts.push(`${failedCount} not found`);
if (cachedCount > 0) parts.push(`${cachedCount} from cache`);
setInfo(parts.join(', '));
} else if (skippedCount > 0) {
setWarning(`All ${skippedCount} tracks already exist`);
@@ -196,14 +217,11 @@ export async function downloadSpotifyPlaylist(
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
// Generate m3u8 file
const m3u8Tracks: M3U8Track[] = successfulTracks.map(({ deezerTrack }) => {
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');
const relativePath = makeRelativePath(absolutePath, playlistsFolder);
return {
duration: deezerTrack.duration,
@@ -213,12 +231,9 @@ export async function downloadSpotifyPlaylist(
};
});
// 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 {
@@ -227,7 +242,8 @@ export async function downloadSpotifyPlaylist(
total: spotifyTracks.length,
queued: queuedCount,
skipped: skippedCount,
failed: failedCount
failed: failedCount,
cached: cachedCount
}
};
}

View File

@@ -51,7 +51,7 @@
// Load tracks and cover art in parallel
const [tracksData, coverPath] = await Promise.all([
loadPlaylistTracks(playlist.path, playlist.name, $settings.musicFolder),
loadPlaylistTracks(playlist.path, playlist.name),
findPlaylistArt(playlist.path)
]);

View File

@@ -352,6 +352,9 @@
onTrackClick={handleTrackClick}
onDownloadTrack={handleDownloadTrack}
onDownloadPlaylist={handleDownloadPlaylist}
onRefresh={refreshPlaylistTracks}
{refreshing}
{lastCached}
{downloadingTrackIds}
/>
{/if}

View File

@@ -8,6 +8,7 @@
getCachedPlaylistTracks,
getCachedTracks,
upsertPlaylistTracks,
upsertTracks,
type SpotifyPlaylist,
type SpotifyPlaylistTrack,
type SpotifyTrack
@@ -22,12 +23,14 @@
let playlistId = $derived($page.params.id!);
let loading = $state(true);
let refreshing = $state(false);
let error = $state<string | null>(null);
let playlist = $state<SpotifyPlaylist | null>(null);
let playlistTracks = $state<SpotifyPlaylistTrack[]>([]);
let selectedTrackIndex = $state<number | null>(null);
let coverImageUrl = $state<string | undefined>(undefined);
let downloadingTrackIds = $state(new Set<string>());
let lastCached = $state<number | null>(null);
// Convert Spotify tracks to Track type for CollectionView
let tracks = $derived<Track[]>(
@@ -96,6 +99,8 @@
cached_at: track.cached_at
}));
lastCached = allTracks[0]?.cached_at || null;
// Set cover art from first track's album
if (allTracks.length > 0) {
if (allTracks[0].album_image_url) {
@@ -171,6 +176,7 @@
} else {
playlist = cachedPlaylist;
coverImageUrl = playlist.image_url;
lastCached = playlist.cached_at;
}
// Load tracks
@@ -204,6 +210,56 @@
}
}
function ensureSpotifyAuth(): boolean {
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret || !$spotifyAuth.refreshToken) {
return false;
}
spotifyAPI.setClientCredentials($spotifyAuth.clientId, $spotifyAuth.clientSecret);
spotifyAPI.setTokens($spotifyAuth.accessToken, $spotifyAuth.refreshToken, $spotifyAuth.expiresAt!);
return true;
}
async function refreshPlaylist() {
if (refreshing) return;
if (!ensureSpotifyAuth()) {
setError('Not logged in to Spotify');
return;
}
refreshing = true;
try {
if (playlistId === 'spotify-likes') {
// Refresh liked tracks
const apiTracks = await spotifyAPI.getAllUserTracks();
await upsertTracks(apiTracks);
// Reload from cache
await loadSpotifyLikes();
} else {
// Refresh regular playlist tracks
const apiTracks = await spotifyAPI.getPlaylistTracks(playlistId);
await upsertPlaylistTracks(playlistId, apiTracks);
// Reload from cache
const cachedTracks = await getCachedPlaylistTracks(playlistId);
playlistTracks = cachedTracks;
if (playlist) {
playlist.track_count = cachedTracks.length;
}
}
lastCached = Math.floor(Date.now() / 1000);
console.log('[Spotify Playlist] Refresh complete!');
} catch (e) {
console.error('Error refreshing playlist:', e);
setError('Error refreshing playlist: ' + (e instanceof Error ? e.message : String(e)));
} finally {
refreshing = false;
}
}
function handleTrackClick(index: number) {
selectedTrackIndex = index;
}
@@ -294,6 +350,9 @@
onTrackClick={handleTrackClick}
onDownloadTrack={handleDownloadTrack}
onDownloadPlaylist={handleDownloadPlaylist}
onRefresh={refreshPlaylist}
{refreshing}
{lastCached}
/>
</div>
{/if}