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", "byteorder",
"futures-util", "futures-util",
"id3", "id3",
"image",
"md5", "md5",
"metaflac", "metaflac",
"reqwest", "reqwest",
@@ -424,6 +425,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.10.1" version = "1.10.1"
@@ -1949,7 +1956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"png", "png 0.17.16",
] ]
[[package]] [[package]]
@@ -2076,6 +2083,21 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -2491,6 +2513,16 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "muda" name = "muda"
version = "0.17.1" version = "0.17.1"
@@ -2506,7 +2538,7 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"once_cell", "once_cell",
"png", "png 0.17.16",
"serde", "serde",
"thiserror 2.0.17", "thiserror 2.0.17",
"windows-sys 0.60.2", "windows-sys 0.60.2",
@@ -3280,6 +3312,19 @@ dependencies = [
"miniz_oxide", "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]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -3408,6 +3453,12 @@ dependencies = [
"psl-types", "psl-types",
] ]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.37.5" version = "0.37.5"
@@ -4821,7 +4872,7 @@ dependencies = [
"ico", "ico",
"json-patch", "json-patch",
"plist", "plist",
"png", "png 0.17.16",
"proc-macro2", "proc-macro2",
"quote", "quote",
"semver", "semver",
@@ -5516,7 +5567,7 @@ dependencies = [
"objc2-core-graphics", "objc2-core-graphics",
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"once_cell", "once_cell",
"png", "png 0.17.16",
"serde", "serde",
"thiserror 2.0.17", "thiserror 2.0.17",
"windows-sys 0.59.0", "windows-sys 0.59.0",
@@ -6818,6 +6869,21 @@ dependencies = [
"syn 2.0.106", "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]] [[package]]
name = "zvariant" name = "zvariant"
version = "5.7.0" version = "5.7.0"

View File

@@ -40,4 +40,5 @@ tauri-plugin-os = "2"
walkdir = "2.5.0" walkdir = "2.5.0"
unicode-normalization = "0.1.24" unicode-normalization = "0.1.24"
tauri-plugin-oauth = "2.0.0" 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) 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) /// Decrypt Deezer track data (legacy - kept for backwards compatibility)
#[tauri::command] #[tauri::command]
async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>, String> { 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, kind: MigrationKind::Up,
}]; }];
let spotify_migrations = vec![Migration { let spotify_migrations = vec![
version: 1, Migration {
description: "create_spotify_cache_tables", version: 1,
sql: " description: "create_spotify_cache_tables",
sql: "
CREATE TABLE IF NOT EXISTS spotify_playlists ( CREATE TABLE IF NOT EXISTS spotify_playlists (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, 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_track ON spotify_playlist_tracks(track_id);
CREATE INDEX IF NOT EXISTS idx_spotify_playlist_tracks_isrc ON spotify_playlist_tracks(isrc); 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() tauri::Builder::default()
.plugin(tauri_plugin_oauth::init()) .plugin(tauri_plugin_oauth::init())
@@ -387,6 +413,7 @@ pub fn run() {
greet, greet,
tag_audio_file, tag_audio_file,
read_audio_metadata, read_audio_metadata,
reencode_cover_image,
decrypt_deezer_track, decrypt_deezer_track,
download_and_decrypt_track, download_and_decrypt_track,
device_sync::index_and_compare, device_sync::index_and_compare,

View File

@@ -2,8 +2,10 @@ use id3::{
frame::{Picture, PictureType}, frame::{Picture, PictureType},
Tag as ID3Tag, TagLike, Version, Tag as ID3Tag, TagLike, Version,
}; };
use image::codecs::jpeg::JpegEncoder;
use metaflac::Tag as FlacTag; use metaflac::Tag as FlacTag;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::io::Cursor;
use std::path::Path; use std::path::Path;
/// Metadata structure for audio file tagging /// Metadata structure for audio file tagging
@@ -356,3 +358,20 @@ fn detect_mime_type_str(data: &[u8]) -> &'static str {
"image/jpeg" "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; onTrackClick?: (index: number) => void;
onDownloadTrack?: (index: number) => void; onDownloadTrack?: (index: number) => void;
onDownloadPlaylist?: () => void; onDownloadPlaylist?: () => void;
onRefresh?: () => void;
refreshing?: boolean;
lastCached?: number | null;
downloadingTrackIds?: Set<string>; downloadingTrackIds?: Set<string>;
} }
@@ -27,9 +30,18 @@
onTrackClick, onTrackClick,
onDownloadTrack, onDownloadTrack,
onDownloadPlaylist, onDownloadPlaylist,
onRefresh,
refreshing = false,
lastCached = null,
downloadingTrackIds = new Set() downloadingTrackIds = new Set()
}: Props = $props(); }: 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'; type ViewMode = 'tracks' | 'info';
let viewMode = $state<ViewMode>('tracks'); let viewMode = $state<ViewMode>('tracks');
@@ -177,14 +189,27 @@
<span class="field-label">Tracks:</span> <span class="field-label">Tracks:</span>
<span>{tracks.length}</span> <span>{tracks.length}</span>
</div> </div>
{#if lastCached}
<div class="field-row">
<span class="field-label">Last updated:</span>
<span>{formatTimestamp(lastCached)}</span>
</div>
{/if}
</fieldset> </fieldset>
<fieldset style="margin-top: 16px;"> <fieldset style="margin-top: 16px;">
<legend>Actions</legend> <legend>Actions</legend>
<button onclick={onDownloadPlaylist}> <div class="actions-row">
Download Playlist <div>
</button> <button onclick={onDownloadPlaylist}>Download Playlist</button>
<p class="help-text">Download all tracks and save as m3u8 playlist</p> <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> </fieldset>
</div> </div>
{/if} {/if}
@@ -300,6 +325,13 @@
text-align: center; text-align: center;
} }
.actions-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.download-btn { .download-btn {
padding: 2px 8px; padding: 2px 8px;
font-size: 11px; font-size: 11px;

View File

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

View File

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

View File

@@ -277,23 +277,22 @@ export async function findPlaylistCoverFallback(
*/ */
export async function loadPlaylistTracks( export async function loadPlaylistTracks(
playlistPath: string, playlistPath: string,
playlistName: string, playlistName: string
baseFolder: string
): Promise<PlaylistWithTracks> { ): Promise<PlaylistWithTracks> {
const parsedTracks = await parsePlaylist(playlistPath); 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 // Load tracks with metadata in parallel
const tracks: Track[] = await Promise.all( const tracks: Track[] = await Promise.all(
parsedTracks.map(async (parsedTrack) => { parsedTracks.map(async (parsedTrack) => {
const trackPath = parsedTrack.path; 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(':\\') let fullPath = trackPath.startsWith('/') || trackPath.includes(':\\')
? trackPath // Absolute path ? trackPath
: `${baseFolder}/${trackPath}`; // Relative path : normalizePath(`${playlistDir}/${trackPath}`);
// Normalize path to remove .. and . segments for Tauri security
fullPath = normalizePath(fullPath);
// Try to find the actual file (handles track number mismatches) // Try to find the actual file (handles track number mismatches)
const actualPath = await findActualFilePath(fullPath); 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 * Clear all Spotify cache
*/ */

View File

@@ -87,14 +87,20 @@ export async function downloadTrack(
// Get user settings // Get user settings
const appSettings = get(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; let coverData: Uint8Array | undefined;
if ((appSettings.embedCoverArt || appSettings.saveCoverToFolder) && track.albumCoverUrl) { if ((appSettings.embedCoverArt || appSettings.saveCoverToFolder) && track.albumCoverUrl) {
try { try {
console.log('Downloading cover art...'); 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) { } 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}`; const absolutePath = `${paths.filepath}/${paths.filename}`;
// Convert to relative path from playlists folder // Convert to relative path from playlists folder
const relativePath = makeRelativePath(absolutePath, 'Music'); const relativePath = makeRelativePath(absolutePath, playlistsFolder);
return { return {
duration: track.duration, 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 * 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'; import { addToQueue } from '$lib/stores/downloadQueue';
@@ -13,6 +17,7 @@ import { setInfo, setSuccess, setWarning } from '$lib/stores/status';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { mkdir } from '@tauri-apps/plugin-fs'; import { mkdir } from '@tauri-apps/plugin-fs';
import { convertSpotifyTrackToDeezer, type SpotifyTrackInput } from './converter'; import { convertSpotifyTrackToDeezer, type SpotifyTrackInput } from './converter';
import { getCachedConversions, cacheConversion } from '$lib/library/spotify-database';
import type { DeezerTrack } from '$lib/types/deezer'; import type { DeezerTrack } from '$lib/types/deezer';
export interface SpotifyPlaylistTrack { export interface SpotifyPlaylistTrack {
@@ -26,16 +31,8 @@ export interface SpotifyPlaylistTrack {
} }
/** /**
* Download a Spotify playlist by converting tracks to Deezer equivalents * Download a Spotify playlist by converting tracks to Deezer equivalents.
* - Converts all tracks via ISRC matching * Cached conversions are reused — only uncached tracks hit the Deezer API.
* - 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( export async function downloadSpotifyPlaylist(
playlistName: string, playlistName: string,
@@ -49,12 +46,12 @@ export async function downloadSpotifyPlaylist(
queued: number; queued: number;
skipped: number; skipped: number;
failed: number; failed: number;
cached: number;
}; };
}> { }> {
const appSettings = get(settings); const appSettings = get(settings);
const authState = get(deezerAuth); const authState = get(deezerAuth);
// Ensure Deezer is authenticated
if (!authState.loggedIn || !authState.arl) { if (!authState.loggedIn || !authState.arl) {
throw new Error('Deezer authentication required for downloads'); 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] Starting download for playlist: ${playlistName}`);
console.log(`[SpotifyPlaylistDownloader] Tracks: ${spotifyTracks.length}`); console.log(`[SpotifyPlaylistDownloader] Tracks: ${spotifyTracks.length}`);
// Ensure playlists folder exists
try { try {
await mkdir(playlistsFolder, { recursive: true }); await mkdir(playlistsFolder, { recursive: true });
} catch (error) { } catch (error) {
// Folder might already exist // 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 queuedCount = 0;
let skippedCount = 0; let skippedCount = 0;
let failedCount = 0; let failedCount = 0;
// Track successful conversions for m3u8 generation
const successfulTracks: Array<{ const successfulTracks: Array<{
deezerTrack: DeezerTrack; deezerTrack: DeezerTrack;
spotifyTrack: SpotifyPlaylistTrack; spotifyTrack: SpotifyPlaylistTrack;
}> = []; }> = [];
// Convert and queue each track
for (const spotifyTrack of spotifyTracks) { for (const spotifyTrack of spotifyTracks) {
try { try {
// Convert Spotify track to Deezer let deezerTrack: DeezerTrack;
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); // --- Check cache first ---
const cached = cachedConversions.get(spotifyTrack.track_id);
if (!conversionResult.success || !conversionResult.deezerTrack) { if (cached) {
console.warn( // Use cached conversion — zero API calls
`[SpotifyPlaylistDownloader] Failed to convert: ${spotifyTrack.name} - ${conversionResult.error}` 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; // Check if track already exists locally
// 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) { if (!appSettings.deezerOverwrite && appSettings.musicFolder) {
const exists = await trackExists(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat); const exists = await trackExists(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat);
if (exists) { if (exists) {
console.log(`[SpotifyPlaylistDownloader] Skipping "${deezerTrack.title}" - already exists`);
skippedCount++; skippedCount++;
// Still add to successful tracks for m3u8 generation
successfulTracks.push({ deezerTrack, spotifyTrack }); successfulTracks.push({ deezerTrack, spotifyTrack });
continue; continue;
} }
@@ -170,10 +194,6 @@ export async function downloadSpotifyPlaylist(
queuedCount++; queuedCount++;
successfulTracks.push({ deezerTrack, spotifyTrack }); successfulTracks.push({ deezerTrack, spotifyTrack });
console.log(
`[SpotifyPlaylistDownloader] Queued: ${deezerTrack.title} (matched via ${conversionResult.matchMethod})`
);
} catch (error) { } catch (error) {
console.error(`[SpotifyPlaylistDownloader] Error processing track ${spotifyTrack.name}:`, error); console.error(`[SpotifyPlaylistDownloader] Error processing track ${spotifyTrack.name}:`, error);
failedCount++; failedCount++;
@@ -181,7 +201,7 @@ export async function downloadSpotifyPlaylist(
} }
console.log( console.log(
`[SpotifyPlaylistDownloader] Queued ${queuedCount} tracks, skipped ${skippedCount}, failed ${failedCount}` `[SpotifyPlaylistDownloader] Queued ${queuedCount}, skipped ${skippedCount}, failed ${failedCount}, cached ${cachedCount}`
); );
// Show queue status message // Show queue status message
@@ -189,6 +209,7 @@ export async function downloadSpotifyPlaylist(
const parts = [`Queued ${queuedCount} track${queuedCount !== 1 ? 's' : ''}`]; const parts = [`Queued ${queuedCount} track${queuedCount !== 1 ? 's' : ''}`];
if (skippedCount > 0) parts.push(`${skippedCount} skipped`); if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
if (failedCount > 0) parts.push(`${failedCount} not found`); if (failedCount > 0) parts.push(`${failedCount} not found`);
if (cachedCount > 0) parts.push(`${cachedCount} from cache`);
setInfo(parts.join(', ')); setInfo(parts.join(', '));
} else if (skippedCount > 0) { } else if (skippedCount > 0) {
setWarning(`All ${skippedCount} tracks already exist`); setWarning(`All ${skippedCount} tracks already exist`);
@@ -196,14 +217,11 @@ export async function downloadSpotifyPlaylist(
setWarning(`Could not find ${failedCount} tracks on Deezer`); setWarning(`Could not find ${failedCount} tracks on Deezer`);
} }
// Generate m3u8 file using Deezer track paths // Generate m3u8 file
const m3u8Tracks: M3U8Track[] = successfulTracks.map(({ deezerTrack, spotifyTrack }) => { const m3u8Tracks: M3U8Track[] = successfulTracks.map(({ deezerTrack }) => {
// Generate expected path for this Deezer track
const paths = generateTrackPath(deezerTrack, musicFolder, appSettings.deezerFormat, false); const paths = generateTrackPath(deezerTrack, musicFolder, appSettings.deezerFormat, false);
const absolutePath = `${paths.filepath}/${paths.filename}`; const absolutePath = `${paths.filepath}/${paths.filename}`;
const relativePath = makeRelativePath(absolutePath, playlistsFolder);
// Convert to relative path from playlists folder
const relativePath = makeRelativePath(absolutePath, 'Music');
return { return {
duration: deezerTrack.duration, duration: deezerTrack.duration,
@@ -213,12 +231,9 @@ export async function downloadSpotifyPlaylist(
}; };
}); });
// Write m3u8 file
const m3u8Path = await writeM3U8(playlistName, m3u8Tracks, playlistsFolder); const m3u8Path = await writeM3U8(playlistName, m3u8Tracks, playlistsFolder);
console.log(`[SpotifyPlaylistDownloader] Playlist saved to: ${m3u8Path}`); console.log(`[SpotifyPlaylistDownloader] Playlist saved to: ${m3u8Path}`);
// Show success message for playlist creation
setSuccess(`Playlist created: ${playlistName} (${successfulTracks.length} tracks)`); setSuccess(`Playlist created: ${playlistName} (${successfulTracks.length} tracks)`);
return { return {
@@ -227,7 +242,8 @@ export async function downloadSpotifyPlaylist(
total: spotifyTracks.length, total: spotifyTracks.length,
queued: queuedCount, queued: queuedCount,
skipped: skippedCount, skipped: skippedCount,
failed: failedCount failed: failedCount,
cached: cachedCount
} }
}; };
} }

View File

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

View File

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

View File

@@ -8,6 +8,7 @@
getCachedPlaylistTracks, getCachedPlaylistTracks,
getCachedTracks, getCachedTracks,
upsertPlaylistTracks, upsertPlaylistTracks,
upsertTracks,
type SpotifyPlaylist, type SpotifyPlaylist,
type SpotifyPlaylistTrack, type SpotifyPlaylistTrack,
type SpotifyTrack type SpotifyTrack
@@ -22,12 +23,14 @@
let playlistId = $derived($page.params.id!); let playlistId = $derived($page.params.id!);
let loading = $state(true); let loading = $state(true);
let refreshing = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let playlist = $state<SpotifyPlaylist | null>(null); let playlist = $state<SpotifyPlaylist | null>(null);
let playlistTracks = $state<SpotifyPlaylistTrack[]>([]); let playlistTracks = $state<SpotifyPlaylistTrack[]>([]);
let selectedTrackIndex = $state<number | null>(null); let selectedTrackIndex = $state<number | null>(null);
let coverImageUrl = $state<string | undefined>(undefined); let coverImageUrl = $state<string | undefined>(undefined);
let downloadingTrackIds = $state(new Set<string>()); let downloadingTrackIds = $state(new Set<string>());
let lastCached = $state<number | null>(null);
// Convert Spotify tracks to Track type for CollectionView // Convert Spotify tracks to Track type for CollectionView
let tracks = $derived<Track[]>( let tracks = $derived<Track[]>(
@@ -96,6 +99,8 @@
cached_at: track.cached_at cached_at: track.cached_at
})); }));
lastCached = allTracks[0]?.cached_at || null;
// Set cover art from first track's album // Set cover art from first track's album
if (allTracks.length > 0) { if (allTracks.length > 0) {
if (allTracks[0].album_image_url) { if (allTracks[0].album_image_url) {
@@ -171,6 +176,7 @@
} else { } else {
playlist = cachedPlaylist; playlist = cachedPlaylist;
coverImageUrl = playlist.image_url; coverImageUrl = playlist.image_url;
lastCached = playlist.cached_at;
} }
// Load tracks // 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) { function handleTrackClick(index: number) {
selectedTrackIndex = index; selectedTrackIndex = index;
} }
@@ -294,6 +350,9 @@
onTrackClick={handleTrackClick} onTrackClick={handleTrackClick}
onDownloadTrack={handleDownloadTrack} onDownloadTrack={handleDownloadTrack}
onDownloadPlaylist={handleDownloadPlaylist} onDownloadPlaylist={handleDownloadPlaylist}
onRefresh={refreshPlaylist}
{refreshing}
{lastCached}
/> />
</div> </div>
{/if} {/if}