mirror of
https://github.com/markuryy/shark.git
synced 2026-06-18 18:41:03 +00:00
Compare commits
5 Commits
a846080677
...
3d7d3ded1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d7d3ded1c | ||
|
|
2beae8e327 | ||
|
|
cc92640908 | ||
|
|
e5d12c9041 | ||
|
|
2c471370e4 |
74
src-tauri/Cargo.lock
generated
74
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
]);
|
||||
|
||||
|
||||
@@ -352,6 +352,9 @@
|
||||
onTrackClick={handleTrackClick}
|
||||
onDownloadTrack={handleDownloadTrack}
|
||||
onDownloadPlaylist={handleDownloadPlaylist}
|
||||
onRefresh={refreshPlaylistTracks}
|
||||
{refreshing}
|
||||
{lastCached}
|
||||
{downloadingTrackIds}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user