Compare commits

...

10 Commits

25 changed files with 4349 additions and 8 deletions

View File

@@ -4,6 +4,7 @@
"": {
"name": "shark",
"dependencies": {
"@fabianlars/tauri-plugin-oauth": "2",
"@noble/ciphers": "^2.0.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
@@ -84,6 +85,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
"@fabianlars/tauri-plugin-oauth": ["@fabianlars/tauri-plugin-oauth@2.0.0", "", { "dependencies": { "@tauri-apps/api": "^2.0.3" } }, "sha512-I1s08ZXrsFuYfNWusAcpLyiCfr5TCvaBrRuKfTG+XQrcaqnAcwjdWH0U5J9QWuMDLwCUMnVxdobtMJzPR8raxQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],

View File

@@ -14,6 +14,7 @@
"license": "UNLICENSED",
"private": true,
"dependencies": {
"@fabianlars/tauri-plugin-oauth": "2",
"@noble/ciphers": "^2.0.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",

18
src-tauri/Cargo.lock generated
View File

@@ -20,12 +20,15 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-oauth",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-sql",
"tauri-plugin-store",
"tokio",
"unicode-normalization",
"walkdir",
]
[[package]]
@@ -4929,6 +4932,21 @@ dependencies = [
"urlpattern",
]
[[package]]
name = "tauri-plugin-oauth"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eda564acdb23185caf700f89dd6e5d4540225d6a991516b2cad0cbcf27e4dcd3"
dependencies = [
"httparse",
"log",
"serde",
"tauri",
"tauri-plugin",
"thiserror 1.0.69",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.0"

View File

@@ -37,4 +37,7 @@ reqwest = { version = "0.12.23", features = ["stream", "rustls-tls"] }
tokio = { version = "1.47.1", features = ["fs", "io-util"] }
futures-util = "0.3.31"
tauri-plugin-os = "2"
walkdir = "2.5.0"
unicode-normalization = "0.1.24"
tauri-plugin-oauth = "2.0.0"

View File

@@ -72,12 +72,28 @@
},
{
"url": "https://lrclib.net/**"
},
{
"url": "https://accounts.spotify.com/**"
},
{
"url": "https://api.spotify.com/**"
}
]
},
"sql:default",
"sql:allow-execute",
"process:default",
"os:default"
"os:default",
"oauth:allow-start",
"oauth:allow-cancel",
{
"identifier": "opener:allow-open-url",
"allow": [
{
"url": "https://accounts.spotify.com/*"
}
]
}
]
}

View File

@@ -0,0 +1,241 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Emitter};
use unicode_normalization::UnicodeNormalization;
use walkdir::WalkDir;
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileInfo {
relative_path: String,
size: u64,
status: String, // "new" | "updated"
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncDiff {
files_to_copy: Vec<FileInfo>,
stats: SyncStats,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncStats {
new_files: usize,
updated_files: usize,
unchanged_files: usize,
total_size: u64,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncProgress {
current: usize,
total: usize,
current_file: String,
status: String,
}
struct FileMetadata {
size: u64,
}
fn should_skip_file(path: &Path) -> bool {
let file_name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// Skip system files
matches!(file_name, ".DS_Store" | "Thumbs.db" | "desktop.ini" | ".nomedia")
}
fn should_skip_dir(path: &Path) -> bool {
let dir_name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// Skip temp folders
dir_name == "_temp"
}
/// Normalize a path string to NFC form for consistent comparison
fn normalize_path(path: &Path) -> String {
path.to_string_lossy().nfc().collect::<String>()
}
/// Index device and compare with library
#[tauri::command]
pub async fn index_and_compare(
library_path: String,
device_path: String,
overwrite_mode: String, // "skip" | "different" | "always"
) -> Result<SyncDiff, String> {
let library_path = PathBuf::from(library_path);
let device_path = PathBuf::from(device_path);
// Validate paths exist
if !library_path.exists() {
return Err(format!("Library path does not exist: {}", library_path.display()));
}
if !device_path.exists() {
return Err(format!("Device path does not exist: {}", device_path.display()));
}
// Step 1: Index device - build HashMap of existing files with normalized paths
let mut device_files: HashMap<String, FileMetadata> = HashMap::new();
for entry in WalkDir::new(&device_path)
.follow_links(false)
.into_iter()
.filter_entry(|e| !should_skip_dir(e.path()))
{
let entry = entry.map_err(|e| format!("Error reading device: {}", e))?;
if entry.file_type().is_file() && !should_skip_file(entry.path()) {
let relative_path = entry.path()
.strip_prefix(&device_path)
.map_err(|e| format!("Path error: {}", e))?
.to_path_buf();
if let Ok(metadata) = entry.metadata() {
let normalized = normalize_path(&relative_path);
device_files.insert(normalized, FileMetadata {
size: metadata.len(),
});
}
}
}
// Step 2: Walk library and compare
let mut files_to_copy = Vec::new();
let mut new_count = 0;
let mut updated_count = 0;
let mut unchanged_count = 0;
let mut total_size = 0u64;
for entry in WalkDir::new(&library_path)
.follow_links(false)
.into_iter()
.filter_entry(|e| !should_skip_dir(e.path()))
{
let entry = entry.map_err(|e| format!("Error reading library: {}", e))?;
if entry.file_type().is_file() && !should_skip_file(entry.path()) {
let relative_path = entry.path()
.strip_prefix(&library_path)
.map_err(|e| format!("Path error: {}", e))?
.to_path_buf();
let metadata = entry.metadata()
.map_err(|e| format!("Cannot read file metadata: {}", e))?;
let file_size = metadata.len();
let normalized_path = normalize_path(&relative_path);
// Check if file exists on device (using normalized path)
if let Some(device_meta) = device_files.get(&normalized_path) {
// File exists on device
let size_different = device_meta.size != file_size;
let should_copy = match overwrite_mode.as_str() {
"skip" => false, // Never overwrite
"different" => size_different, // Only if different size
"always" => true, // Always overwrite
_ => size_different, // Default to "different"
};
if should_copy {
files_to_copy.push(FileInfo {
relative_path: relative_path.to_string_lossy().to_string(),
size: file_size,
status: "updated".to_string(),
});
updated_count += 1;
total_size += file_size;
} else {
unchanged_count += 1;
}
} else {
// File doesn't exist on device - new file
files_to_copy.push(FileInfo {
relative_path: relative_path.to_string_lossy().to_string(),
size: file_size,
status: "new".to_string(),
});
new_count += 1;
total_size += file_size;
}
}
}
Ok(SyncDiff {
files_to_copy,
stats: SyncStats {
new_files: new_count,
updated_files: updated_count,
unchanged_files: unchanged_count,
total_size,
},
})
}
/// Sync files to device
#[tauri::command]
pub async fn sync_to_device(
app: AppHandle,
library_path: String,
device_path: String,
files_to_copy: Vec<FileInfo>,
) -> Result<String, String> {
let library_path = PathBuf::from(library_path);
let device_path = PathBuf::from(device_path);
let total = files_to_copy.len();
for (index, file_info) in files_to_copy.iter().enumerate() {
let source = library_path.join(&file_info.relative_path);
let dest = device_path.join(&file_info.relative_path);
// Emit progress
app.emit("sync-progress", SyncProgress {
current: index + 1,
total,
current_file: file_info.relative_path.clone(),
status: format!("Copying {} ({} of {})", file_info.relative_path, index + 1, total),
}).map_err(|e| format!("Failed to emit progress: {}", e))?;
// Create parent directory if needed
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
}
// Copy file
fs::copy(&source, &dest)
.map_err(|e| {
// Check for common errors
match e.kind() {
std::io::ErrorKind::PermissionDenied => {
format!("Permission denied: {}", dest.display())
}
std::io::ErrorKind::NotFound => {
format!("Device disconnected or path not found: {}", dest.display())
}
_ => format!("Failed to copy {}: {}", file_info.relative_path, e)
}
})?;
}
// Emit completion
app.emit("sync-progress", SyncProgress {
current: total,
total,
current_file: String::new(),
status: format!("Sync complete! Copied {} files", total),
}).map_err(|e| format!("Failed to emit completion: {}", e))?;
Ok(format!("Successfully synced {} files to device", total))
}

View File

@@ -1,6 +1,7 @@
use tauri_plugin_sql::{Migration, MigrationKind};
mod deezer_crypto;
mod device_sync;
mod metadata;
mod tagger;
@@ -298,13 +299,83 @@ pub fn run() {
kind: MigrationKind::Up,
}];
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,
track_count INTEGER DEFAULT 0,
owner_name TEXT,
image_url TEXT,
cached_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS spotify_albums (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
artist_name TEXT NOT NULL,
track_count INTEGER DEFAULT 0,
release_date TEXT,
image_url TEXT,
cached_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS spotify_artists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
followers INTEGER DEFAULT 0,
image_url TEXT,
cached_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS spotify_tracks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_name TEXT,
duration_ms INTEGER DEFAULT 0,
isrc TEXT,
album_image_url TEXT,
cached_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS spotify_playlist_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
track_id TEXT NOT NULL,
name TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_name TEXT,
duration_ms INTEGER DEFAULT 0,
track_number INTEGER,
isrc TEXT,
cached_at INTEGER NOT NULL,
UNIQUE(playlist_id, track_id)
);
CREATE INDEX IF NOT EXISTS idx_spotify_playlists_name ON spotify_playlists(name);
CREATE INDEX IF NOT EXISTS idx_spotify_albums_artist ON spotify_albums(artist_name);
CREATE INDEX IF NOT EXISTS idx_spotify_artists_name ON spotify_artists(name);
CREATE INDEX IF NOT EXISTS idx_spotify_tracks_name ON spotify_tracks(name);
CREATE INDEX IF NOT EXISTS idx_spotify_tracks_isrc ON spotify_tracks(isrc);
CREATE INDEX IF NOT EXISTS idx_spotify_playlist_tracks_playlist ON spotify_playlist_tracks(playlist_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);
",
kind: MigrationKind::Up,
}];
tauri::Builder::default()
.plugin(tauri_plugin_oauth::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_process::init())
.plugin(
tauri_plugin_sql::Builder::new()
.add_migrations("sqlite:library.db", library_migrations)
.add_migrations("sqlite:deezer.db", deezer_migrations)
.add_migrations("sqlite:spotify.db", spotify_migrations)
.build(),
)
.plugin(tauri_plugin_http::init())
@@ -317,7 +388,9 @@ pub fn run() {
tag_audio_file,
read_audio_metadata,
decrypt_deezer_track,
download_and_decrypt_track
download_and_decrypt_track,
device_sync::index_and_compare,
device_sync::sync_to_device
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -9,6 +9,7 @@
play: '/icons/speaker.png',
search: '/icons/internet.png',
computer: '/icons/computer.png',
device: '/icons/ipod.svg',
};
let history: string[] = $state([]);
@@ -80,7 +81,12 @@
<img src={icons.search} alt="Search" />
<span>Search</span>
</a>
<a href="/sync" class="toolbar-button" title="Device Sync">
<img src={icons.device} alt="Device Sync" />
<span>Sync</span>
</a>
<a href="/settings" class="toolbar-button" title="Settings">
<img src={icons.computer} alt="Settings" />
<span>Settings</span>

View File

@@ -280,7 +280,7 @@
.track-number {
text-align: center;
opacity: 0.6;
z-index: 0;
}
.duration {

View File

@@ -0,0 +1,324 @@
<script lang="ts">
import type { Track } from '$lib/types/track';
import PageDecoration from '$lib/components/PageDecoration.svelte';
import { deezerAuth } from '$lib/stores/deezer';
interface Props {
title: string;
subtitle?: string;
metadata?: string;
coverImageUrl?: string;
tracks: Track[];
selectedTrackIndex?: number | null;
onTrackClick?: (index: number) => void;
onDownloadTrack?: (index: number) => void;
onDownloadPlaylist?: () => void;
downloadingTrackIds?: Set<string>;
}
let {
title,
subtitle,
metadata,
coverImageUrl,
tracks,
selectedTrackIndex = null,
onTrackClick,
onDownloadTrack,
onDownloadPlaylist,
downloadingTrackIds = new Set()
}: Props = $props();
type ViewMode = 'tracks' | 'info';
let viewMode = $state<ViewMode>('tracks');
function handleTrackClick(index: number) {
if (onTrackClick) {
onTrackClick(index);
}
}
function handleDownloadClick(index: number, event: MouseEvent) {
event.stopPropagation();
if (onDownloadTrack) {
onDownloadTrack(index);
}
}
function isTrackDownloading(track: Track): boolean {
const trackId = (track as any).spotifyId?.toString();
if (!trackId) return false;
return downloadingTrackIds.has(trackId);
}
</script>
<PageDecoration label="SPOTIFY PLAYLIST" />
<!-- Header -->
<div class="collection-header">
{#if coverImageUrl}
<img
src={coverImageUrl}
alt="{title} cover"
class="collection-cover"
/>
{:else}
<div class="collection-cover-placeholder"></div>
{/if}
<div class="collection-info">
<h2>{title}</h2>
{#if subtitle}
<p class="collection-subtitle">{subtitle}</p>
{/if}
{#if metadata}
<p class="collection-metadata">{metadata}</p>
{/if}
</div>
</div>
<section class="collection-content">
<!-- Tabs -->
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
-->
<menu role="tablist">
<li role="tab" aria-selected={viewMode === 'tracks'}>
<button onclick={() => viewMode = 'tracks'}>Tracks</button>
</li>
<li role="tab" aria-selected={viewMode === 'info'}>
<button onclick={() => viewMode = 'info'}>Info</button>
</li>
</menu>
<!-- Tab Content -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if viewMode === 'tracks'}
<!-- Track Listing -->
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th style="width: 50px;">#</th>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Duration</th>
{#if $deezerAuth.loggedIn}
<th style="width: 100px;">Actions</th>
{/if}
</tr>
</thead>
<tbody>
{#each tracks as track, i}
<tr
class:highlighted={selectedTrackIndex === i}
onclick={() => handleTrackClick(i)}
>
<td class="track-number">
{track.metadata.trackNumber ?? i + 1}
</td>
<td>{track.metadata.title ?? '—'}</td>
<td>{track.metadata.artist ?? '—'}</td>
<td>{track.metadata.album ?? '—'}</td>
<td class="duration">
{#if track.metadata.duration}
{Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
{:else}
{/if}
</td>
{#if $deezerAuth.loggedIn}
<td class="actions">
<button
onclick={(e) => handleDownloadClick(i, e)}
disabled={isTrackDownloading(track)}
class="download-btn"
>
{isTrackDownloading(track) ? 'Queued' : 'Download'}
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'info'}
<!-- Playlist Info -->
<div class="info-container">
<fieldset>
<legend>Playlist Information</legend>
<div class="field-row">
<span class="field-label">Title:</span>
<span>{title}</span>
</div>
{#if subtitle}
<div class="field-row">
<span class="field-label">Creator:</span>
<span>{subtitle}</span>
</div>
{/if}
<div class="field-row">
<span class="field-label">Tracks:</span>
<span>{tracks.length}</span>
</div>
</fieldset>
{#if $deezerAuth.loggedIn}
<fieldset style="margin-top: 16px;">
<legend>Actions</legend>
<button onclick={onDownloadPlaylist}>
Download Playlist
</button>
<p class="help-text">Download all tracks via Deezer and save as m3u8 playlist</p>
</fieldset>
{:else}
<fieldset style="margin-top: 16px;">
<legend>Downloads</legend>
<p class="warning-text">Deezer login required to download Spotify tracks</p>
<p class="help-text">Sign in to Deezer in Services → Deezer to enable downloads</p>
</fieldset>
{/if}
</div>
{/if}
</div>
</div>
</section>
<style>
.collection-header {
display: flex;
gap: 16px;
padding: 8px;
margin-bottom: 6px;
flex-shrink: 0;
}
.collection-cover {
width: 152px;
height: 152px;
object-fit: cover;
image-rendering: auto;
flex-shrink: 0;
}
.collection-cover-placeholder {
width: 152px;
height: 152px;
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
background-size: 8px 8px;
flex-shrink: 0;
}
.collection-info {
display: flex;
flex-direction: column;
justify-content: center;
}
h2 {
margin: 0 0 4px 0;
font-size: 1.5em;
}
.collection-subtitle {
margin: 0 0 8px 0;
font-size: 1.1em;
opacity: 0.8;
}
.collection-metadata {
margin: 0;
opacity: 0.6;
font-size: 0.9em;
}
.collection-content {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-content {
margin-top: -2px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.window-body {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.table-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
table {
width: 100%;
}
th {
text-align: left;
}
.track-number {
text-align: center;
z-index: 0;
}
.duration {
font-family: monospace;
font-size: 0.9em;
text-align: center;
width: 80px;
}
.info-container {
padding: 16px;
}
.field-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.field-label {
font-weight: bold;
min-width: 120px;
}
.help-text {
margin: 8px 0 0 0;
font-size: 11px;
color: #808080;
}
.warning-text {
margin: 0 0 8px 0;
font-size: 12px;
color: #c00;
}
.actions {
text-align: center;
}
.download-btn {
padding: 2px 8px;
font-size: 11px;
}
</style>

View File

@@ -115,7 +115,7 @@ export async function getCachedArtists(): Promise<DeezerArtist[]> {
export async function getCachedTracks(): Promise<DeezerTrack[]> {
const database = await initDeezerDatabase();
const tracks = await database.select<DeezerTrack[]>(
'SELECT * FROM deezer_tracks ORDER BY title COLLATE NOCASE'
'SELECT * FROM deezer_tracks ORDER BY ROWID DESC'
);
return tracks || [];
}

View File

@@ -0,0 +1,343 @@
import Database from '@tauri-apps/plugin-sql';
import { remove } from '@tauri-apps/plugin-fs';
import { appConfigDir } from '@tauri-apps/api/path';
export interface SpotifyPlaylist {
id: string;
name: string;
track_count: number;
owner_name: string;
image_url?: string;
cached_at: number;
}
export interface SpotifyAlbum {
id: string;
name: string;
artist_name: string;
track_count: number;
release_date?: string;
image_url?: string;
cached_at: number;
}
export interface SpotifyArtist {
id: string;
name: string;
followers: number;
image_url?: string;
cached_at: number;
}
export interface SpotifyTrack {
id: string;
name: string;
artist_name: string;
album_name: string;
duration_ms: number;
isrc?: string | null;
album_image_url?: string | null;
cached_at: number;
}
export interface SpotifyPlaylistTrack {
id: number;
playlist_id: string;
track_id: string;
name: string;
artist_name: string;
album_name: string;
duration_ms: number;
track_number: number | null;
isrc?: string | null;
cached_at: number;
}
let db: Database | null = null;
/**
* Initialize database connection
*/
export async function initSpotifyDatabase(): Promise<Database> {
if (!db) {
db = await Database.load('sqlite:spotify.db');
}
return db;
}
/**
* Close database connection (for cache clearing)
*/
export async function closeSpotifyDatabase(): Promise<void> {
if (db) {
await db.close();
db = null;
}
}
/**
* Get cached playlists
*/
export async function getCachedPlaylists(): Promise<SpotifyPlaylist[]> {
const database = await initSpotifyDatabase();
const playlists = await database.select<SpotifyPlaylist[]>(
'SELECT * FROM spotify_playlists ORDER BY name COLLATE NOCASE'
);
return playlists || [];
}
/**
* Get cached albums
*/
export async function getCachedAlbums(): Promise<SpotifyAlbum[]> {
const database = await initSpotifyDatabase();
const albums = await database.select<SpotifyAlbum[]>(
'SELECT * FROM spotify_albums ORDER BY artist_name COLLATE NOCASE, name COLLATE NOCASE'
);
return albums || [];
}
/**
* Get cached artists
*/
export async function getCachedArtists(): Promise<SpotifyArtist[]> {
const database = await initSpotifyDatabase();
const artists = await database.select<SpotifyArtist[]>(
'SELECT * FROM spotify_artists ORDER BY name COLLATE NOCASE'
);
return artists || [];
}
/**
* Get cached tracks
*/
export async function getCachedTracks(): Promise<SpotifyTrack[]> {
const database = await initSpotifyDatabase();
const tracks = await database.select<SpotifyTrack[]>(
'SELECT * FROM spotify_tracks ORDER BY ROWID ASC'
);
return tracks || [];
}
/**
* Upsert playlists
*/
export async function upsertPlaylists(playlists: any[]): Promise<void> {
try {
console.log('[spotify-database] Upserting playlists, count:', playlists.length);
if (playlists.length > 0) {
console.log('[spotify-database] First playlist sample:', playlists[0]);
}
const database = await initSpotifyDatabase();
const now = Math.floor(Date.now() / 1000);
// Clear existing playlists
await database.execute('DELETE FROM spotify_playlists');
console.log('[spotify-database] Cleared existing playlists');
// Insert new playlists
for (const playlist of playlists) {
await database.execute(
`INSERT INTO spotify_playlists (id, name, track_count, owner_name, image_url, cached_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
playlist.id,
playlist.name || '',
playlist.tracks?.total || 0,
playlist.owner?.display_name || 'Unknown',
playlist.images?.[0]?.url || null,
now
]
);
}
console.log('[spotify-database] Inserted', playlists.length, 'playlists');
} catch (err) {
console.error('[spotify-database] Error in upsertPlaylists:', err);
throw err;
}
}
/**
* Upsert albums
*/
export async function upsertAlbums(albums: any[]): Promise<void> {
const database = await initSpotifyDatabase();
const now = Math.floor(Date.now() / 1000);
// Clear existing albums
await database.execute('DELETE FROM spotify_albums');
// Insert new albums
for (const album of albums) {
await database.execute(
`INSERT INTO spotify_albums (id, name, artist_name, track_count, release_date, image_url, cached_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
album.album.id,
album.album.name || '',
album.album.artists?.[0]?.name || 'Unknown',
album.album.total_tracks || 0,
album.album.release_date || null,
album.album.images?.[0]?.url || null,
now
]
);
}
}
/**
* Upsert artists
*/
export async function upsertArtists(artists: any[]): Promise<void> {
const database = await initSpotifyDatabase();
const now = Math.floor(Date.now() / 1000);
// Clear existing artists
await database.execute('DELETE FROM spotify_artists');
// Insert new artists
for (const artist of artists) {
await database.execute(
`INSERT INTO spotify_artists (id, name, followers, image_url, cached_at)
VALUES ($1, $2, $3, $4, $5)`,
[
artist.id,
artist.name || '',
artist.followers?.total || 0,
artist.images?.[0]?.url || null,
now
]
);
}
}
/**
* Upsert tracks
*/
export async function upsertTracks(tracks: any[]): Promise<void> {
const database = await initSpotifyDatabase();
const now = Math.floor(Date.now() / 1000);
// Clear existing tracks
await database.execute('DELETE FROM spotify_tracks');
// Insert new tracks
for (const track of tracks) {
await database.execute(
`INSERT INTO spotify_tracks (id, name, artist_name, album_name, duration_ms, isrc, album_image_url, cached_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
track.track.id,
track.track.name || '',
track.track.artists?.[0]?.name || 'Unknown',
track.track.album?.name || '',
track.track.duration_ms || 0,
track.track.external_ids?.isrc || null,
track.track.album?.images?.[0]?.url || null,
now
]
);
}
}
/**
* Get cache timestamp
*/
export async function getCacheTimestamp(): Promise<number | null> {
const database = await initSpotifyDatabase();
const result = await database.select<{ cached_at: number }[]>(
'SELECT cached_at FROM spotify_playlists LIMIT 1'
);
return result[0]?.cached_at || null;
}
/**
* Get cached playlist tracks
*/
export async function getCachedPlaylistTracks(playlistId: string): Promise<SpotifyPlaylistTrack[]> {
const database = await initSpotifyDatabase();
const tracks = await database.select<SpotifyPlaylistTrack[]>(
'SELECT * FROM spotify_playlist_tracks WHERE playlist_id = $1 ORDER BY track_number, id',
[playlistId]
);
return tracks || [];
}
/**
* Get single playlist by ID
*/
export async function getCachedPlaylist(playlistId: string): Promise<SpotifyPlaylist | null> {
const database = await initSpotifyDatabase();
const playlists = await database.select<SpotifyPlaylist[]>(
'SELECT * FROM spotify_playlists WHERE id = $1',
[playlistId]
);
return playlists?.[0] || null;
}
/**
* Upsert playlist tracks
*/
export async function upsertPlaylistTracks(playlistId: string, tracks: any[]): Promise<void> {
try {
console.log('[spotify-database] Upserting playlist tracks, playlistId:', playlistId, 'count:', tracks.length);
const database = await initSpotifyDatabase();
const now = Math.floor(Date.now() / 1000);
// Clear existing tracks for this playlist
await database.execute('DELETE FROM spotify_playlist_tracks WHERE playlist_id = $1', [playlistId]);
console.log('[spotify-database] Cleared existing tracks for playlist:', playlistId);
// Insert new tracks
for (let i = 0; i < tracks.length; i++) {
const item = tracks[i];
const track = item.track;
await database.execute(
`INSERT INTO spotify_playlist_tracks (playlist_id, track_id, name, artist_name, album_name, duration_ms, track_number, isrc, cached_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
playlistId,
track.id,
track.name || '',
track.artists?.[0]?.name || 'Unknown',
track.album?.name || '',
track.duration_ms || 0,
i + 1, // Use position in playlist as track number
track.external_ids?.isrc || null,
now
]
);
}
console.log('[spotify-database] Inserted', tracks.length, 'tracks for playlist:', playlistId);
} catch (err) {
console.error('[spotify-database] Error in upsertPlaylistTracks:', err);
throw err;
}
}
/**
* Clear all Spotify cache
*/
export async function clearSpotifyCache(): Promise<void> {
try {
// Close the database connection
await closeSpotifyDatabase();
// Delete the entire database file
const configDir = await appConfigDir();
const dbPath = `${configDir}/spotify.db`;
await remove(dbPath);
// Reinitialize the database (this will run migrations)
await initSpotifyDatabase();
console.log('[spotify-database] Spotify database file deleted and recreated successfully');
} catch (error) {
console.error('[spotify-database] Error clearing cache:', error);
throw error;
}
}

View File

@@ -0,0 +1,107 @@
/**
* Device sync service layer
* Handles communication with Tauri backend for device synchronization
*/
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
export interface FileInfo {
relativePath: string;
size: number;
status: 'new' | 'updated';
}
export interface SyncStats {
newFiles: number;
updatedFiles: number;
unchangedFiles: number;
totalSize: number;
}
export interface SyncDiff {
filesToCopy: FileInfo[];
stats: SyncStats;
}
export interface SyncProgress {
current: number;
total: number;
currentFile: string;
status: string;
}
export type ProgressCallback = (progress: SyncProgress) => void;
/**
* Index device and compare with library
* Returns a diff showing which files need to be synced
*/
export async function indexAndCompare(
libraryPath: string,
devicePath: string,
overwriteMode: string
): Promise<SyncDiff> {
try {
const result = await invoke<SyncDiff>('index_and_compare', {
libraryPath,
devicePath,
overwriteMode
});
return result;
} catch (error) {
console.error('Error indexing and comparing:', error);
throw new Error(String(error));
}
}
/**
* Sync files to device with progress updates
*/
export async function syncToDevice(
libraryPath: string,
devicePath: string,
filesToCopy: FileInfo[],
onProgress?: ProgressCallback
): Promise<string> {
let unlisten: UnlistenFn | null = null;
try {
// Set up progress listener
unlisten = await listen<SyncProgress>('sync-progress', (event) => {
if (onProgress) {
onProgress(event.payload);
}
});
// Start sync operation
const result = await invoke<string>('sync_to_device', {
libraryPath,
devicePath,
filesToCopy
});
return result;
} catch (error) {
console.error('Error syncing to device:', error);
throw new Error(String(error));
} finally {
// Clean up event listener
if (unlisten) {
unlisten();
}
}
}
/**
* Format bytes to human-readable string
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}

411
src/lib/services/spotify.ts Normal file
View File

@@ -0,0 +1,411 @@
import { fetch } from '@tauri-apps/plugin-http';
import type { SpotifyUser } from '$lib/stores/spotify';
import { isTokenExpired, saveTokens } from '$lib/stores/spotify';
const SPOTIFY_AUTH_URL = 'https://accounts.spotify.com/authorize';
const SPOTIFY_TOKEN_URL = 'https://accounts.spotify.com/api/token';
const SPOTIFY_API_BASE = 'https://api.spotify.com/v1';
// Required scopes for the app
const REQUIRED_SCOPES = [
'user-read-private',
'user-read-email',
'user-library-read',
'playlist-read-private',
'playlist-read-collaborative',
'user-follow-read'
];
/**
* Spotify API client with OAuth 2.0 PKCE flow
*/
export class SpotifyAPI {
private clientId: string | null = null;
private clientSecret: string | null = null;
private accessToken: string | null = null;
private refreshToken: string | null = null;
private expiresAt: number | null = null;
/**
* Set client credentials (developer app credentials)
*/
setClientCredentials(clientId: string, clientSecret: string): void {
this.clientId = clientId;
this.clientSecret = clientSecret;
}
/**
* Set OAuth tokens
*/
setTokens(accessToken: string, refreshToken: string, expiresAt: number): void {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.expiresAt = expiresAt;
}
/**
* Generate a random code verifier for PKCE
*/
generateCodeVerifier(): string {
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const values = crypto.getRandomValues(new Uint8Array(64));
return Array.from(values)
.map(x => possible[x % possible.length])
.join('');
}
/**
* Generate code challenge from verifier using SHA256
*/
async generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hashed = await crypto.subtle.digest('SHA-256', data);
// Base64 URL encode
const base64 = btoa(String.fromCharCode(...new Uint8Array(hashed)))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
return base64;
}
/**
* Get the authorization URL for user to authenticate
* Returns the URL and the code verifier (must be stored for later)
*/
async getAuthorizationUrl(clientId: string, redirectUri: string): Promise<{ url: string; codeVerifier: string }> {
const codeVerifier = this.generateCodeVerifier();
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
const params = new URLSearchParams({
client_id: clientId,
response_type: 'code',
redirect_uri: redirectUri,
code_challenge_method: 'S256',
code_challenge: codeChallenge,
scope: REQUIRED_SCOPES.join(' ')
});
const url = `${SPOTIFY_AUTH_URL}?${params.toString()}`;
return { url, codeVerifier };
}
/**
* Exchange authorization code for access token
*/
async exchangeCodeForToken(
code: string,
codeVerifier: string,
clientId: string,
redirectUri: string
): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
const params = new URLSearchParams({
client_id: clientId,
grant_type: 'authorization_code',
code: code,
redirect_uri: redirectUri,
code_verifier: codeVerifier
});
const response = await fetch(SPOTIFY_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
});
if (!response.ok) {
const errorText = await response.text();
console.error('Token exchange error:', errorText);
throw new Error(`Token exchange failed: ${response.statusText}`);
}
const data = await response.json();
// Store tokens
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token;
this.expiresAt = Date.now() + (data.expires_in * 1000);
return {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_in: data.expires_in
};
}
/**
* Refresh the access token using the refresh token
*/
async refreshAccessToken(): Promise<{ access_token: string; expires_in: number }> {
if (!this.refreshToken || !this.clientId) {
throw new Error('Missing refresh token or client ID');
}
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.refreshToken,
client_id: this.clientId
});
const response = await fetch(SPOTIFY_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
});
if (!response.ok) {
const errorText = await response.text();
console.error('Token refresh error:', errorText);
try {
const errorData = JSON.parse(errorText);
if (errorData.error === 'invalid_grant') {
throw new Error('REFRESH_TOKEN_REVOKED');
}
} catch (e) {
if ((e as Error).message === 'REFRESH_TOKEN_REVOKED') {
throw e;
}
}
throw new Error(`Token refresh failed: ${response.statusText}`);
}
const data = await response.json();
// Update tokens
this.accessToken = data.access_token;
this.expiresAt = Date.now() + (data.expires_in * 1000);
// Note: Spotify may or may not return a new refresh token
const refreshToken = data.refresh_token || this.refreshToken!;
if (data.refresh_token) {
this.refreshToken = data.refresh_token;
}
// Save refreshed tokens to store
await saveTokens(this.accessToken!, refreshToken, data.expires_in);
return {
access_token: data.access_token,
expires_in: data.expires_in
};
}
/**
* Make an authenticated API call to Spotify
* Automatically refreshes token if expired
*/
async apiCall<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
// Check if token needs refresh
if (isTokenExpired(this.expiresAt)) {
console.log('[Spotify] Token expired, refreshing...');
await this.refreshAccessToken();
}
if (!this.accessToken) {
throw new Error('No access token available');
}
const url = `${SPOTIFY_API_BASE}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
}
});
if (!response.ok) {
const errorText = await response.text();
console.error(`Spotify API error for ${endpoint}:`, errorText);
throw new Error(`API call failed: ${response.statusText}`);
}
return response.json() as Promise<T>;
}
/**
* Get current user's profile
*/
async getCurrentUser(): Promise<SpotifyUser> {
return this.apiCall<SpotifyUser>('/me');
}
/**
* Get user's playlists
*/
async getUserPlaylists(limit: number = 50, offset: number = 0): Promise<any> {
return this.apiCall(`/me/playlists?limit=${limit}&offset=${offset}`);
}
/**
* Get user's saved tracks
*/
async getUserTracks(limit: number = 50, offset: number = 0): Promise<any> {
return this.apiCall(`/me/tracks?limit=${limit}&offset=${offset}`);
}
/**
* Get user's saved albums
*/
async getUserAlbums(limit: number = 50, offset: number = 0): Promise<any> {
return this.apiCall(`/me/albums?limit=${limit}&offset=${offset}`);
}
/**
* Get user's followed artists
*/
async getUserArtists(limit: number = 50, after?: string): Promise<any> {
const afterParam = after ? `&after=${after}` : '';
return this.apiCall(`/me/following?type=artist&limit=${limit}${afterParam}`);
}
/**
* Get all user playlists (handles pagination)
*/
async getAllUserPlaylists(): Promise<any[]> {
const allPlaylists: any[] = [];
let offset = 0;
const limit = 50;
while (true) {
const response = await this.getUserPlaylists(limit, offset);
const playlists = response.items || [];
allPlaylists.push(...playlists);
if (!response.next || playlists.length < limit) {
break;
}
offset += limit;
}
console.log('[Spotify] Fetched', allPlaylists.length, 'playlists');
return allPlaylists;
}
/**
* Get all user saved tracks (handles pagination)
*/
async getAllUserTracks(): Promise<any[]> {
const allTracks: any[] = [];
let offset = 0;
const limit = 50;
while (true) {
const response = await this.getUserTracks(limit, offset);
const tracks = response.items || [];
allTracks.push(...tracks);
if (!response.next || tracks.length < limit) {
break;
}
offset += limit;
}
console.log('[Spotify] Fetched', allTracks.length, 'saved tracks');
return allTracks;
}
/**
* Get all user saved albums (handles pagination)
*/
async getAllUserAlbums(): Promise<any[]> {
const allAlbums: any[] = [];
let offset = 0;
const limit = 50;
while (true) {
const response = await this.getUserAlbums(limit, offset);
const albums = response.items || [];
allAlbums.push(...albums);
if (!response.next || albums.length < limit) {
break;
}
offset += limit;
}
console.log('[Spotify] Fetched', allAlbums.length, 'saved albums');
return allAlbums;
}
/**
* Get all user followed artists (handles pagination)
*/
async getAllUserArtists(): Promise<any[]> {
const allArtists: any[] = [];
let after: string | undefined = undefined;
const limit = 50;
while (true) {
const response = await this.getUserArtists(limit, after);
const artists = response.artists?.items || [];
allArtists.push(...artists);
if (!response.artists?.next || artists.length < limit) {
break;
}
// Extract the 'after' cursor from the next URL
if (response.artists?.cursors?.after) {
after = response.artists.cursors.after;
} else {
break;
}
}
console.log('[Spotify] Fetched', allArtists.length, 'followed artists');
return allArtists;
}
/**
* Get tracks for a specific playlist (handles pagination)
*/
async getPlaylistTracks(playlistId: string): Promise<any[]> {
const allTracks: any[] = [];
let offset = 0;
const limit = 100;
while (true) {
const response = await this.apiCall<any>(`/playlists/${playlistId}/tracks?limit=${limit}&offset=${offset}`);
const tracks = response.items || [];
allTracks.push(...tracks);
if (!response.next || tracks.length < limit) {
break;
}
offset += limit;
}
console.log('[Spotify] Fetched', allTracks.length, 'tracks for playlist', playlistId);
return allTracks;
}
/**
* Get a single playlist by ID
*/
async getPlaylist(playlistId: string): Promise<any> {
return this.apiCall<any>(`/playlists/${playlistId}`);
}
}
// Export singleton instance
export const spotifyAPI = new SpotifyAPI();

View File

@@ -0,0 +1,216 @@
/**
* Utility to add a Spotify track to the download queue by converting it to Deezer
* Uses ISRC matching to find the equivalent Deezer track
*/
import { deezerAPI } from '../deezer';
import { addToQueue } from '$lib/stores/downloadQueue';
import { settings } from '$lib/stores/settings';
import { deezerAuth } from '$lib/stores/deezer';
import { trackExists } from '../deezer/downloader';
import { setInfo, setWarning, setError } from '$lib/stores/status';
import { get } from 'svelte/store';
import { convertSpotifyTrackToDeezer, type SpotifyTrackInput } from './converter';
import type { DeezerTrack } from '$lib/types/deezer';
export interface SpotifyTrackData {
id: string;
name: string;
artist_name: string;
album_name: string;
duration_ms: number;
isrc?: string | null;
}
/**
* Add a Spotify track to the download queue by converting it to Deezer
* @param spotifyTrack - Spotify track data (from cache or API)
* @returns Result object with success status and details
*/
export async function addSpotifyTrackToQueue(
spotifyTrack: SpotifyTrackData
): Promise<{
success: boolean;
deezerId?: string;
matchMethod?: string;
reason?: string;
}> {
// Ensure Deezer authentication
const authState = get(deezerAuth);
if (!authState.loggedIn || !authState.arl) {
setError('Deezer login required for downloads');
return {
success: false,
reason: 'deezer_auth_required'
};
}
deezerAPI.setArl(authState.arl);
try {
// Convert Spotify track to Deezer
console.log(`[AddSpotifyToQueue] Converting: ${spotifyTrack.name} by ${spotifyTrack.artist_name}`);
const conversionInput: SpotifyTrackInput = {
id: spotifyTrack.id,
name: spotifyTrack.name,
artists: [spotifyTrack.artist_name],
album: spotifyTrack.album_name,
duration_ms: spotifyTrack.duration_ms,
isrc: spotifyTrack.isrc
};
const conversionResult = await convertSpotifyTrackToDeezer(conversionInput);
if (!conversionResult.success || !conversionResult.deezerTrack) {
const errorMsg = `Could not find "${spotifyTrack.name}" on Deezer`;
console.warn(`[AddSpotifyToQueue] ${errorMsg}`);
setWarning(errorMsg);
return {
success: false,
reason: conversionResult.error || 'conversion_failed'
};
}
const deezerPublicTrack = conversionResult.deezerTrack;
const deezerTrackId = deezerPublicTrack.id.toString();
console.log(
`[AddSpotifyToQueue] Matched to Deezer track: ${deezerTrackId} via ${conversionResult.matchMethod}`
);
// Fetch full track data from Deezer GW API
const deezerFullTrack = await deezerAPI.getTrack(deezerTrackId);
if (!deezerFullTrack || !deezerFullTrack.SNG_ID) {
const errorMsg = 'Failed to fetch full Deezer track data';
console.error(`[AddSpotifyToQueue] ${errorMsg}`);
setError(errorMsg);
return {
success: false,
reason: 'deezer_fetch_failed'
};
}
// Fetch album data for cover art
let albumData = null;
try {
albumData = await deezerAPI.getAlbumData(deezerFullTrack.ALB_ID.toString());
} catch (error) {
console.warn('[AddSpotifyToQueue] Could not fetch album data:', error);
}
// Fetch lyrics
let lyricsData = null;
try {
lyricsData = await deezerAPI.getLyrics(deezerFullTrack.SNG_ID.toString());
} catch (error) {
console.warn('[AddSpotifyToQueue] Could not fetch lyrics:', error);
}
// Parse lyrics if available
let lyrics = undefined;
if (lyricsData) {
let syncLrc = '';
if (lyricsData.LYRICS_SYNC_JSON) {
for (const line of lyricsData.LYRICS_SYNC_JSON) {
const text = line.line || '';
const timestamp = line.lrc_timestamp || '[00:00.00]';
syncLrc += `${timestamp}${text}\n`;
}
}
lyrics = {
sync: syncLrc || undefined,
unsync: lyricsData.LYRICS_TEXT || undefined,
syncID3: undefined
};
}
// Build full DeezerTrack object
const deezerTrack: DeezerTrack = {
id: parseInt(deezerFullTrack.SNG_ID, 10),
title: deezerFullTrack.SNG_TITLE,
artist: deezerFullTrack.ART_NAME,
artistId: parseInt(deezerFullTrack.ART_ID, 10),
artists: [deezerFullTrack.ART_NAME],
album: deezerFullTrack.ALB_TITLE,
albumId: parseInt(deezerFullTrack.ALB_ID, 10),
albumArtist: deezerFullTrack.ART_NAME,
albumArtistId: parseInt(deezerFullTrack.ART_ID, 10),
trackNumber:
typeof deezerFullTrack.TRACK_NUMBER === 'number'
? deezerFullTrack.TRACK_NUMBER
: parseInt(deezerFullTrack.TRACK_NUMBER, 10),
discNumber:
typeof deezerFullTrack.DISK_NUMBER === 'number'
? deezerFullTrack.DISK_NUMBER
: parseInt(deezerFullTrack.DISK_NUMBER, 10),
duration:
typeof deezerFullTrack.DURATION === 'number'
? deezerFullTrack.DURATION
: parseInt(deezerFullTrack.DURATION, 10),
explicit: deezerFullTrack.EXPLICIT_LYRICS === 1,
md5Origin: deezerFullTrack.MD5_ORIGIN,
mediaVersion: deezerFullTrack.MEDIA_VERSION,
trackToken: deezerFullTrack.TRACK_TOKEN,
// Enhanced metadata
lyrics,
albumCoverUrl: albumData?.ALB_PICTURE
? `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/500x500-000000-80-0-0.jpg`
: undefined,
albumCoverXlUrl: albumData?.ALB_PICTURE
? `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/1000x1000-000000-80-0-0.jpg`
: undefined,
label: albumData?.LABEL_NAME,
barcode: albumData?.UPC,
releaseDate: deezerFullTrack.PHYSICAL_RELEASE_DATE,
genre: deezerFullTrack.GENRE ? [deezerFullTrack.GENRE] : undefined,
copyright: deezerFullTrack.COPYRIGHT
};
// Check if we should skip this track (if it exists and overwrite is false)
const appSettings = get(settings);
if (!appSettings.deezerOverwrite && appSettings.musicFolder) {
const exists = await trackExists(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat);
if (exists) {
console.log(`[AddSpotifyToQueue] Skipping "${deezerTrack.title}" - already exists`);
setWarning(`Skipped: ${deezerTrack.title} (already exists)`);
return {
success: false,
deezerId: deezerTrackId,
matchMethod: conversionResult.matchMethod,
reason: 'already_exists'
};
}
}
// Add to queue
await addToQueue({
source: 'deezer',
type: 'track',
title: deezerTrack.title,
artist: deezerTrack.artist,
totalTracks: 1,
downloadObject: deezerTrack
});
setInfo(`Queued: ${deezerTrack.title}`);
return {
success: true,
deezerId: deezerTrackId,
matchMethod: conversionResult.matchMethod
};
} catch (error) {
const errorMsg = `Error adding track to queue: ${error instanceof Error ? error.message : 'Unknown error'}`;
console.error('[AddSpotifyToQueue]', errorMsg);
setError(errorMsg);
return {
success: false,
reason: 'queue_error'
};
}
}

View File

@@ -0,0 +1,215 @@
/**
* Spotify to Deezer track conversion utilities
* Matches Spotify tracks to Deezer tracks using ISRC codes (primary) or metadata search (fallback)
*/
import { fetch } from '@tauri-apps/plugin-http';
import { deezerAPI } from '../deezer';
export interface SpotifyTrackInput {
id: string;
name: string;
artists: string[];
album: string;
duration_ms: number;
isrc?: string | null;
}
export interface DeezerMatchResult {
success: boolean;
deezerTrack?: any;
matchMethod?: 'isrc' | 'metadata' | 'none';
error?: string;
}
/**
* Search Deezer for a track by ISRC code
* This is the primary and most reliable matching method
*/
export async function searchDeezerByISRC(isrc: string): Promise<any | null> {
if (!isrc || isrc.trim().length === 0) {
return null;
}
try {
console.log(`[Converter] Searching Deezer by ISRC: ${isrc}`);
const url = `https://api.deezer.com/2.0/track/isrc:${encodeURIComponent(isrc)}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
'Accept': 'application/json'
},
connectTimeout: 30000
});
if (!response.ok) {
console.warn(`[Converter] ISRC search failed with status: ${response.status}`);
return null;
}
const result = await response.json();
// Check if we got an error response
if (result.error) {
console.warn(`[Converter] ISRC search returned error:`, result.error);
return null;
}
// Valid track found
if (result.id) {
console.log(`[Converter] Found Deezer track by ISRC: ${result.id} - ${result.title}`);
return result;
}
return null;
} catch (error) {
console.error('[Converter] Error searching by ISRC:', error);
return null;
}
}
/**
* Search Deezer for a track by metadata (title + artist)
* Used as fallback when ISRC is not available or doesn't match
*/
export async function searchDeezerByMetadata(
title: string,
artist: string,
durationMs?: number
): Promise<any | null> {
try {
// Build search query: "artist title"
const query = `${artist} ${title}`.trim();
console.log(`[Converter] Searching Deezer by metadata: "${query}"`);
const searchResults = await deezerAPI.searchTracks(query, 10);
if (!searchResults.data || searchResults.data.length === 0) {
console.warn(`[Converter] No results found for: "${query}"`);
return null;
}
// Try to find best match
// Priority: exact title match, then duration match (±2 seconds)
const durationSec = durationMs ? Math.floor(durationMs / 1000) : undefined;
for (const track of searchResults.data) {
// Check title similarity (case-insensitive)
const titleMatch = track.title.toLowerCase() === title.toLowerCase();
// Check duration if available (within 2 seconds tolerance)
const durationMatch = !durationSec || Math.abs(track.duration - durationSec) <= 2;
if (titleMatch && durationMatch) {
console.log(`[Converter] Found exact match by metadata: ${track.id} - ${track.title}`);
return track;
}
}
// If no exact match, return first result as best guess
const firstResult = searchResults.data[0];
console.log(`[Converter] Using first result as best match: ${firstResult.id} - ${firstResult.title}`);
return firstResult;
} catch (error) {
console.error('[Converter] Error searching by metadata:', error);
return null;
}
}
/**
* Convert a Spotify track to Deezer track ID
* Uses ISRC matching first, falls back to metadata search
*/
export async function convertSpotifyTrackToDeezer(
spotifyTrack: SpotifyTrackInput
): Promise<DeezerMatchResult> {
console.log(`[Converter] Converting Spotify track: ${spotifyTrack.name} by ${spotifyTrack.artists.join(', ')}`);
// Try ISRC matching first (most reliable)
if (spotifyTrack.isrc) {
const deezerTrack = await searchDeezerByISRC(spotifyTrack.isrc);
if (deezerTrack) {
return {
success: true,
deezerTrack,
matchMethod: 'isrc'
};
}
console.log(`[Converter] ISRC match failed for: ${spotifyTrack.isrc}`);
}
// Fallback to metadata search
const artist = spotifyTrack.artists[0] || 'Unknown';
const deezerTrack = await searchDeezerByMetadata(
spotifyTrack.name,
artist,
spotifyTrack.duration_ms
);
if (deezerTrack) {
return {
success: true,
deezerTrack,
matchMethod: 'metadata'
};
}
// No match found
console.warn(`[Converter] Could not find Deezer match for: ${spotifyTrack.name} by ${artist}`);
return {
success: false,
matchMethod: 'none',
error: 'No match found on Deezer'
};
}
/**
* Convert multiple Spotify tracks to Deezer track IDs
* Returns both successful conversions and failed tracks
*/
export async function convertSpotifyTracksBatch(
spotifyTracks: SpotifyTrackInput[]
): Promise<{
conversions: Array<{ spotifyId: string; deezerId: string; matchMethod: string }>;
failures: Array<{ spotifyId: string; name: string; artist: string; error: string }>;
}> {
const conversions: Array<{ spotifyId: string; deezerId: string; matchMethod: string }> = [];
const failures: Array<{ spotifyId: string; name: string; artist: string; error: string }> = [];
console.log(`[Converter] Converting ${spotifyTracks.length} Spotify tracks to Deezer...`);
for (const track of spotifyTracks) {
try {
const result = await convertSpotifyTrackToDeezer(track);
if (result.success && result.deezerTrack) {
conversions.push({
spotifyId: track.id,
deezerId: result.deezerTrack.id.toString(),
matchMethod: result.matchMethod || 'unknown'
});
} else {
failures.push({
spotifyId: track.id,
name: track.name,
artist: track.artists[0] || 'Unknown',
error: result.error || 'Unknown error'
});
}
} catch (error) {
console.error(`[Converter] Error converting track ${track.name}:`, error);
failures.push({
spotifyId: track.id,
name: track.name,
artist: track.artists[0] || 'Unknown',
error: error instanceof Error ? error.message : 'Conversion error'
});
}
}
console.log(`[Converter] Conversion complete: ${conversions.length} successful, ${failures.length} failed`);
return { conversions, failures };
}

View File

@@ -0,0 +1,233 @@
/**
* Download Spotify playlist - converts tracks to Deezer via ISRC, adds to queue, and creates m3u8 file
*/
import { addToQueue } from '$lib/stores/downloadQueue';
import { trackExists } from '$lib/services/deezer/downloader';
import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8';
import { generateTrackPath } from '$lib/services/deezer/paths';
import { settings } from '$lib/stores/settings';
import { deezerAuth } from '$lib/stores/deezer';
import { deezerAPI } from '$lib/services/deezer';
import { setInfo, setSuccess, setWarning } from '$lib/stores/status';
import { get } from 'svelte/store';
import { mkdir } from '@tauri-apps/plugin-fs';
import { convertSpotifyTrackToDeezer, type SpotifyTrackInput } from './converter';
import type { DeezerTrack } from '$lib/types/deezer';
export interface SpotifyPlaylistTrack {
id: number | string;
track_id: string;
name: string;
artist_name: string;
album_name: string;
duration_ms: number;
isrc?: string | null;
}
/**
* Download a Spotify playlist by converting tracks to Deezer equivalents
* - Converts all tracks via ISRC matching
* - Adds converted tracks to the download queue (respects overwrite setting)
* - Creates an m3u8 playlist file with relative paths
*
* @param playlistName - Name of the playlist
* @param spotifyTracks - Array of Spotify track objects
* @param playlistsFolder - Path to playlists folder
* @param musicFolder - Path to music folder
* @returns Object with m3u8 path and statistics
*/
export async function downloadSpotifyPlaylist(
playlistName: string,
spotifyTracks: SpotifyPlaylistTrack[],
playlistsFolder: string,
musicFolder: string
): Promise<{
m3u8Path: string;
stats: {
total: number;
queued: number;
skipped: number;
failed: number;
};
}> {
const appSettings = get(settings);
const authState = get(deezerAuth);
// Ensure Deezer is authenticated
if (!authState.loggedIn || !authState.arl) {
throw new Error('Deezer authentication required for downloads');
}
deezerAPI.setArl(authState.arl);
console.log(`[SpotifyPlaylistDownloader] Starting download for playlist: ${playlistName}`);
console.log(`[SpotifyPlaylistDownloader] Tracks: ${spotifyTracks.length}`);
// Ensure playlists folder exists
try {
await mkdir(playlistsFolder, { recursive: true });
} catch (error) {
// Folder might already exist
}
// Track statistics
let queuedCount = 0;
let skippedCount = 0;
let failedCount = 0;
// Track successful conversions for m3u8 generation
const successfulTracks: Array<{
deezerTrack: DeezerTrack;
spotifyTrack: SpotifyPlaylistTrack;
}> = [];
// Convert and queue each track
for (const spotifyTrack of spotifyTracks) {
try {
// Convert Spotify track to Deezer
const conversionInput: SpotifyTrackInput = {
id: spotifyTrack.track_id,
name: spotifyTrack.name,
artists: [spotifyTrack.artist_name],
album: spotifyTrack.album_name,
duration_ms: spotifyTrack.duration_ms,
isrc: spotifyTrack.isrc
};
const conversionResult = await convertSpotifyTrackToDeezer(conversionInput);
if (!conversionResult.success || !conversionResult.deezerTrack) {
console.warn(
`[SpotifyPlaylistDownloader] Failed to convert: ${spotifyTrack.name} - ${conversionResult.error}`
);
failedCount++;
continue;
}
const deezerPublicTrack = conversionResult.deezerTrack;
// Fetch full track data from Deezer GW API (needed for download)
const deezerTrackId = deezerPublicTrack.id.toString();
const deezerFullTrack = await deezerAPI.getTrack(deezerTrackId);
if (!deezerFullTrack || !deezerFullTrack.SNG_ID) {
console.warn(`[SpotifyPlaylistDownloader] Could not fetch full Deezer track data for ID: ${deezerTrackId}`);
failedCount++;
continue;
}
// Build DeezerTrack object
const deezerTrack: DeezerTrack = {
id: parseInt(deezerFullTrack.SNG_ID, 10),
title: deezerFullTrack.SNG_TITLE,
artist: deezerFullTrack.ART_NAME,
artistId: parseInt(deezerFullTrack.ART_ID, 10),
artists: [deezerFullTrack.ART_NAME],
album: deezerFullTrack.ALB_TITLE,
albumId: parseInt(deezerFullTrack.ALB_ID, 10),
albumArtist: deezerFullTrack.ART_NAME,
albumArtistId: parseInt(deezerFullTrack.ART_ID, 10),
trackNumber:
typeof deezerFullTrack.TRACK_NUMBER === 'number'
? deezerFullTrack.TRACK_NUMBER
: parseInt(deezerFullTrack.TRACK_NUMBER, 10),
discNumber:
typeof deezerFullTrack.DISK_NUMBER === 'number'
? deezerFullTrack.DISK_NUMBER
: parseInt(deezerFullTrack.DISK_NUMBER, 10),
duration:
typeof deezerFullTrack.DURATION === 'number'
? deezerFullTrack.DURATION
: parseInt(deezerFullTrack.DURATION, 10),
explicit: deezerFullTrack.EXPLICIT_LYRICS === 1,
md5Origin: deezerFullTrack.MD5_ORIGIN,
mediaVersion: deezerFullTrack.MEDIA_VERSION,
trackToken: deezerFullTrack.TRACK_TOKEN
};
// Check if track already exists (if overwrite is disabled)
if (!appSettings.deezerOverwrite && appSettings.musicFolder) {
const exists = await trackExists(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat);
if (exists) {
console.log(`[SpotifyPlaylistDownloader] Skipping "${deezerTrack.title}" - already exists`);
skippedCount++;
// Still add to successful tracks for m3u8 generation
successfulTracks.push({ deezerTrack, spotifyTrack });
continue;
}
}
// Queue track for download
await addToQueue({
source: 'deezer',
type: 'track',
title: deezerTrack.title,
artist: deezerTrack.artist,
totalTracks: 1,
downloadObject: deezerTrack
});
queuedCount++;
successfulTracks.push({ deezerTrack, spotifyTrack });
console.log(
`[SpotifyPlaylistDownloader] Queued: ${deezerTrack.title} (matched via ${conversionResult.matchMethod})`
);
} catch (error) {
console.error(`[SpotifyPlaylistDownloader] Error processing track ${spotifyTrack.name}:`, error);
failedCount++;
}
}
console.log(
`[SpotifyPlaylistDownloader] Queued ${queuedCount} tracks, skipped ${skippedCount}, failed ${failedCount}`
);
// Show queue status message
if (queuedCount > 0) {
const parts = [`Queued ${queuedCount} track${queuedCount !== 1 ? 's' : ''}`];
if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
if (failedCount > 0) parts.push(`${failedCount} not found`);
setInfo(parts.join(', '));
} else if (skippedCount > 0) {
setWarning(`All ${skippedCount} tracks already exist`);
} else if (failedCount > 0) {
setWarning(`Could not find ${failedCount} tracks on Deezer`);
}
// Generate m3u8 file using Deezer track paths
const m3u8Tracks: M3U8Track[] = successfulTracks.map(({ deezerTrack, spotifyTrack }) => {
// Generate expected path for this Deezer track
const paths = generateTrackPath(deezerTrack, musicFolder, appSettings.deezerFormat, false);
const absolutePath = `${paths.filepath}/${paths.filename}`;
// Convert to relative path from playlists folder
const relativePath = makeRelativePath(absolutePath, 'Music');
return {
duration: deezerTrack.duration,
artist: deezerTrack.artist,
title: deezerTrack.title,
path: relativePath
};
});
// Write m3u8 file
const m3u8Path = await writeM3U8(playlistName, m3u8Tracks, playlistsFolder);
console.log(`[SpotifyPlaylistDownloader] Playlist saved to: ${m3u8Path}`);
// Show success message for playlist creation
setSuccess(`Playlist created: ${playlistName} (${successfulTracks.length} tracks)`);
return {
m3u8Path,
stats: {
total: spotifyTracks.length,
queued: queuedCount,
skipped: skippedCount,
failed: failedCount
}
};
}

View File

@@ -0,0 +1,81 @@
import { LazyStore } from '@tauri-apps/plugin-store';
import { writable, type Writable } from 'svelte/store';
// Device sync settings interface
export type OverwriteMode = 'skip' | 'different' | 'always';
export interface DeviceSyncSettings {
musicPath: string | null;
playlistsPath: string | null;
overwriteMode: OverwriteMode;
}
// Initialize the store with device-sync.json
const store = new LazyStore('device-sync.json');
// Default settings
const defaultSettings: DeviceSyncSettings = {
musicPath: null,
playlistsPath: null,
overwriteMode: 'different'
};
// Create a writable store for reactive UI updates
export const deviceSyncSettings: Writable<DeviceSyncSettings> = writable(defaultSettings);
// Load settings from store
export async function loadDeviceSyncSettings(): Promise<void> {
const musicPath = await store.get<string>('musicPath');
const playlistsPath = await store.get<string>('playlistsPath');
const overwriteMode = await store.get<OverwriteMode>('overwriteMode');
deviceSyncSettings.set({
musicPath: musicPath ?? null,
playlistsPath: playlistsPath ?? null,
overwriteMode: overwriteMode ?? 'different'
});
}
// Save device music path setting
export async function setMusicPath(path: string | null): Promise<void> {
if (path) {
await store.set('musicPath', path);
} else {
await store.delete('musicPath');
}
await store.save();
deviceSyncSettings.update(s => ({
...s,
musicPath: path
}));
}
// Save device playlists path setting
export async function setPlaylistsPath(path: string | null): Promise<void> {
if (path) {
await store.set('playlistsPath', path);
} else {
await store.delete('playlistsPath');
}
await store.save();
deviceSyncSettings.update(s => ({
...s,
playlistsPath: path
}));
}
// Save overwrite mode setting
export async function setOverwriteMode(mode: OverwriteMode): Promise<void> {
await store.set('overwriteMode', mode);
await store.save();
deviceSyncSettings.update(s => ({
...s,
overwriteMode: mode
}));
}
// Initialize settings on import
loadDeviceSyncSettings();

148
src/lib/stores/spotify.ts Normal file
View File

@@ -0,0 +1,148 @@
import { LazyStore } from '@tauri-apps/plugin-store';
import { writable, type Writable } from 'svelte/store';
// Spotify User interface
export interface SpotifyUser {
id: string;
display_name: string;
email?: string;
country?: string;
product?: string; // premium, free, etc.
images?: Array<{ url: string }>;
}
// Spotify auth state
export interface SpotifyAuthState {
// Developer credentials
clientId: string | null;
clientSecret: string | null;
// OAuth tokens
accessToken: string | null;
refreshToken: string | null;
expiresAt: number | null; // Unix timestamp in milliseconds
// User data
user: SpotifyUser | null;
loggedIn: boolean;
cacheTimestamp: number | null; // Unix timestamp in seconds
}
// Initialize the store with spotify.json
const store = new LazyStore('spotify.json');
// Default state
const defaultState: SpotifyAuthState = {
clientId: null,
clientSecret: null,
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null,
loggedIn: false,
cacheTimestamp: null
};
// Create a writable store for reactive UI updates
export const spotifyAuth: Writable<SpotifyAuthState> = writable(defaultState);
// Load Spotify auth state from store
export async function loadSpotifyAuth(): Promise<void> {
const clientId = await store.get<string>('clientId');
const clientSecret = await store.get<string>('clientSecret');
const accessToken = await store.get<string>('accessToken');
const refreshToken = await store.get<string>('refreshToken');
const expiresAt = await store.get<number>('expiresAt');
const user = await store.get<SpotifyUser>('user');
const cacheTimestamp = await store.get<number>('cacheTimestamp');
spotifyAuth.set({
clientId: clientId ?? null,
clientSecret: clientSecret ?? null,
accessToken: accessToken ?? null,
refreshToken: refreshToken ?? null,
expiresAt: expiresAt ?? null,
user: user ?? null,
loggedIn: !!(accessToken && user),
cacheTimestamp: cacheTimestamp ?? null
});
}
// Save client credentials (developer app credentials)
export async function saveClientCredentials(clientId: string, clientSecret: string): Promise<void> {
await store.set('clientId', clientId);
await store.set('clientSecret', clientSecret);
await store.save();
spotifyAuth.update(s => ({
...s,
clientId,
clientSecret
}));
}
// Save OAuth tokens
export async function saveTokens(accessToken: string, refreshToken: string, expiresIn: number): Promise<void> {
const expiresAt = Date.now() + (expiresIn * 1000);
await store.set('accessToken', accessToken);
await store.set('refreshToken', refreshToken);
await store.set('expiresAt', expiresAt);
await store.save();
spotifyAuth.update(s => ({
...s,
accessToken,
refreshToken,
expiresAt
}));
}
// Save user data
export async function saveUser(user: SpotifyUser): Promise<void> {
await store.set('user', user);
await store.save();
spotifyAuth.update(s => ({
...s,
user,
loggedIn: true
}));
}
// Clear auth (logout)
export async function clearSpotifyAuth(): Promise<void> {
await store.delete('accessToken');
await store.delete('refreshToken');
await store.delete('expiresAt');
await store.delete('user');
await store.save();
spotifyAuth.update(s => ({
...s,
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null,
loggedIn: false
}));
}
// Check if token is expired or about to expire (within 5 minutes)
export function isTokenExpired(expiresAt: number | null): boolean {
if (!expiresAt) return true;
const bufferTime = 5 * 60 * 1000; // 5 minutes in milliseconds
return Date.now() >= (expiresAt - bufferTime);
}
// Save cache timestamp
export async function saveCacheTimestamp(timestamp: number): Promise<void> {
await store.set('cacheTimestamp', timestamp);
await store.save();
spotifyAuth.update(s => ({
...s,
cacheTimestamp: timestamp
}));
}
// Initialize on module load
loadSpotifyAuth();

View File

@@ -132,10 +132,10 @@
Services
</summary>
<div class="nav-submenu">
<!-- <a href="/services/spotify" class="nav-item nav-subitem">
<a href="/services/spotify" class="nav-item nav-subitem">
<img src="/icons/spotify.png" alt="" class="nav-icon" />
Spotify
</a> -->
</a>
<a href="/services/deezer" class="nav-item nav-subitem">
<img src="/icons/deezer.png" alt="" class="nav-icon" />
Deezer

View File

@@ -239,7 +239,6 @@
title: dbTrack.title,
artist: dbTrack.artist_name,
album: dbTrack.album_title || undefined,
trackNumber: dbTrack.track_number || undefined,
duration: dbTrack.duration
}
};

View File

@@ -0,0 +1,944 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { spotifyAuth, loadSpotifyAuth, saveClientCredentials, saveTokens, saveUser, clearSpotifyAuth, saveCacheTimestamp } from '$lib/stores/spotify';
import { spotifyAPI } from '$lib/services/spotify';
import { start, onUrl } from '@fabianlars/tauri-plugin-oauth';
import { openUrl } from '@tauri-apps/plugin-opener';
import {
getCachedPlaylists,
getCachedAlbums,
getCachedArtists,
getCachedTracks,
upsertPlaylists,
upsertAlbums,
upsertArtists,
upsertTracks,
type SpotifyPlaylist,
type SpotifyAlbum,
type SpotifyArtist,
type SpotifyTrack
} from '$lib/library/spotify-database';
// Fixed port for OAuth callback - user must register this in Spotify Dashboard
const OAUTH_PORT = 8228;
const REDIRECT_URI = `http://127.0.0.1:${OAUTH_PORT}/callback`;
// Login form state
let clientIdInput = $state('');
let clientSecretInput = $state('');
let isAuthenticating = $state(false);
let loginError = $state('');
let loginSuccess = $state('');
// OAuth state
let isWaitingForCallback = $state(false);
let oauthUnlisten: (() => void) | null = $state(null);
// Data state
type ViewMode = 'playlists' | 'tracks' | 'artists' | 'albums' | 'info';
let viewMode = $state<ViewMode>('playlists');
let playlists = $state<SpotifyPlaylist[]>([]);
let albums = $state<SpotifyAlbum[]>([]);
let artists = $state<SpotifyArtist[]>([]);
let tracks = $state<SpotifyTrack[]>([]);
let loading = $state(true);
let syncing = $state(false);
let error = $state<string | null>(null);
let selectedIndex = $state<number | null>(null);
// User refresh state
let refreshingUser = $state(false);
let userRefreshMessage = $state('');
const CACHE_DURATION = 24 * 60 * 60; // 24 hours in seconds
onMount(async () => {
await loadSpotifyAuth();
// Check if we have client credentials stored
if ($spotifyAuth.clientId) {
clientIdInput = $spotifyAuth.clientId;
}
if ($spotifyAuth.clientSecret) {
clientSecretInput = $spotifyAuth.clientSecret;
}
if ($spotifyAuth.loggedIn) {
await loadFavorites();
} else {
loading = false;
}
});
async function loadFavorites() {
loading = true;
error = null;
try {
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
spotifyAPI.setTokens(
$spotifyAuth.accessToken!,
$spotifyAuth.refreshToken!,
$spotifyAuth.expiresAt!
);
// Check if we need to refresh cache
const now = Math.floor(Date.now() / 1000);
const cacheAge = $spotifyAuth.cacheTimestamp ? now - $spotifyAuth.cacheTimestamp : Infinity;
const needsRefresh = cacheAge > CACHE_DURATION;
if (needsRefresh) {
await refreshFavorites();
} else {
// Load from cache
const [cachedPlaylists, cachedAlbums, cachedArtists, cachedTracks] = await Promise.all([
getCachedPlaylists(),
getCachedAlbums(),
getCachedArtists(),
getCachedTracks()
]);
playlists = cachedPlaylists;
albums = cachedAlbums;
artists = cachedArtists;
tracks = cachedTracks;
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
if (errorMessage === 'REFRESH_TOKEN_REVOKED') {
await clearSpotifyAuth();
error = 'Your Spotify session has expired. Please log in again.';
} else {
error = 'Error loading favorites: ' + errorMessage;
}
viewMode = 'info';
} finally {
loading = false;
}
}
async function refreshFavorites() {
if (!$spotifyAuth.accessToken || syncing) {
return;
}
syncing = true;
error = null;
try {
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
spotifyAPI.setTokens(
$spotifyAuth.accessToken,
$spotifyAuth.refreshToken!,
$spotifyAuth.expiresAt!
);
// Fetch all favorites from API
console.log('[Spotify] Fetching favorites from API...');
const [apiPlaylists, apiAlbums, apiArtists, apiTracks] = await Promise.all([
spotifyAPI.getAllUserPlaylists(),
spotifyAPI.getAllUserAlbums(),
spotifyAPI.getAllUserArtists(),
spotifyAPI.getAllUserTracks()
]);
console.log('[Spotify] Fetched from API:', {
playlists: apiPlaylists.length,
albums: apiAlbums.length,
artists: apiArtists.length,
tracks: apiTracks.length
});
// Update database cache
console.log('[Spotify] Updating database cache...');
await upsertPlaylists(apiPlaylists);
await upsertAlbums(apiAlbums);
await upsertArtists(apiArtists);
await upsertTracks(apiTracks);
// Update cache timestamp
const now = Math.floor(Date.now() / 1000);
await saveCacheTimestamp(now);
console.log('[Spotify] Reloading from cache...');
// Reload from cache
const [cachedPlaylists, cachedAlbums, cachedArtists, cachedTracks] = await Promise.all([
getCachedPlaylists(),
getCachedAlbums(),
getCachedArtists(),
getCachedTracks()
]);
playlists = cachedPlaylists;
albums = cachedAlbums;
artists = cachedArtists;
tracks = cachedTracks;
console.log('[Spotify] Refresh complete!');
} catch (e) {
console.error('Error refreshing favorites:', e);
const errorMessage = e instanceof Error ? e.message : String(e);
if (errorMessage === 'REFRESH_TOKEN_REVOKED') {
await clearSpotifyAuth();
error = 'Your Spotify session has expired. Please log in again.';
} else {
error = 'Error refreshing favorites: ' + errorMessage;
}
viewMode = 'info';
} finally {
syncing = false;
}
}
async function handleAuthorize() {
if (!clientIdInput || !clientSecretInput) {
loginError = 'Please enter both Client ID and Client Secret';
return;
}
if (clientIdInput.trim().length === 0 || clientSecretInput.trim().length === 0) {
loginError = 'Client ID and Client Secret cannot be empty';
return;
}
isAuthenticating = true;
loginError = '';
loginSuccess = '';
try {
// Save credentials
await saveClientCredentials(clientIdInput.trim(), clientSecretInput.trim());
// Clean up any existing OAuth listener
if (oauthUnlisten) {
oauthUnlisten();
}
// Set up OAuth callback listener and store unlisten function
oauthUnlisten = await onUrl((callbackUrl) => {
handleOAuthCallback(callbackUrl);
});
// Start OAuth server on fixed port with custom styled response
const port = await start({
ports: [OAUTH_PORT],
response: `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Spotify Authorization Complete</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
background: #008080;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.window {
background: silver;
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
padding: 3px;
max-width: 500px;
width: 100%;
}
.title-bar {
background: linear-gradient(90deg, navy, #1084d0);
padding: 3px 5px;
display: flex;
align-items: center;
justify-content: space-between;
color: white;
font-weight: bold;
font-size: 11px;
}
.window-body {
background: silver;
padding: 16px;
margin: 3px;
}
h1 {
font-size: 16px;
margin-bottom: 12px;
}
p {
font-size: 11px;
line-height: 1.5;
margin-bottom: 8px;
}
</style>
</head>
<body>
<div class="window">
<div class="title-bar">
<span>Spotify Authorization</span>
</div>
<div class="window-body">
<h1>Authorization Complete</h1>
<p>You have successfully authorized Shark with your Spotify account.</p>
<p>You can close this window and return to the app.</p>
</div>
</div>
</body>
</html>
`
});
console.log(`[Spotify] OAuth server started on port ${port}`);
// Generate authorization URL
const { url, codeVerifier } = await spotifyAPI.getAuthorizationUrl(
clientIdInput.trim(),
REDIRECT_URI
);
// Store code verifier for callback
localStorage.setItem('spotify_code_verifier', codeVerifier);
isWaitingForCallback = true;
// Open Spotify authorization in default browser
await openUrl(url);
} catch (error) {
console.error('[Spotify] Authorization error:', error);
loginError = `Authorization error: ${error instanceof Error ? error.message : JSON.stringify(error)}`;
isAuthenticating = false;
isWaitingForCallback = false;
}
}
async function handleOAuthCallback(callbackUrl: string) {
// Immediately remove the listener to prevent duplicate calls from hot reload
if (oauthUnlisten) {
oauthUnlisten();
oauthUnlisten = null;
}
try {
// Parse the callback URL
const url = new URL(callbackUrl);
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
if (error) {
throw new Error(`Authorization failed: ${error}`);
}
if (!code) {
throw new Error('No authorization code received');
}
// Retrieve code verifier from localStorage
const codeVerifier = localStorage.getItem('spotify_code_verifier');
if (!codeVerifier) {
throw new Error('OAuth state lost. Please try logging in again.');
}
// Exchange code for tokens
const tokenData = await spotifyAPI.exchangeCodeForToken(
code,
codeVerifier,
$spotifyAuth.clientId!,
REDIRECT_URI
);
// Save tokens
await saveTokens(tokenData.access_token, tokenData.refresh_token, tokenData.expires_in);
// Set tokens in API client
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
spotifyAPI.setTokens(
tokenData.access_token,
tokenData.refresh_token,
Date.now() + (tokenData.expires_in * 1000)
);
// Fetch user info
const user = await spotifyAPI.getCurrentUser();
await saveUser(user);
// Load favorites after successful login
await loadFavorites();
// Clean up
localStorage.removeItem('spotify_code_verifier');
} catch (error) {
loginError = `Authentication error: ${error instanceof Error ? error.message : 'Unknown error'}`;
localStorage.removeItem('spotify_code_verifier');
} finally {
isAuthenticating = false;
isWaitingForCallback = false;
}
}
async function handleLogout() {
await clearSpotifyAuth();
clientIdInput = '';
clientSecretInput = '';
loginSuccess = '';
loginError = '';
playlists = [];
albums = [];
artists = [];
tracks = [];
}
async function handleRefreshUser() {
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret) {
return;
}
refreshingUser = true;
userRefreshMessage = '';
try {
// Set credentials in API client
spotifyAPI.setClientCredentials($spotifyAuth.clientId, $spotifyAuth.clientSecret);
spotifyAPI.setTokens(
$spotifyAuth.accessToken,
$spotifyAuth.refreshToken!,
$spotifyAuth.expiresAt!
);
// Fetch updated user info
const user = await spotifyAPI.getCurrentUser();
await saveUser(user);
userRefreshMessage = 'User info refreshed successfully!';
setTimeout(() => {
userRefreshMessage = '';
}, 3000);
} catch (error) {
userRefreshMessage = 'Error refreshing user info: ' + (error instanceof Error ? error.message : 'Unknown error');
} finally {
refreshingUser = false;
setTimeout(() => {
userRefreshMessage = '';
}, 3000);
}
}
function handleItemClick(index: number) {
selectedIndex = index;
}
function handlePlaylistDoubleClick(playlistId: string) {
goto(`/services/spotify/playlists/${playlistId}`);
}
function formatDuration(ms: number): string {
const mins = Math.floor(ms / 60000);
const secs = Math.floor((ms % 60000) / 1000);
return `${mins}:${String(secs).padStart(2, '0')}`;
}
</script>
<div class="spotify-wrapper">
<h2 style="padding: 8px">Spotify</h2>
{#if !$spotifyAuth.loggedIn}
<!-- Login Form -->
<section class="window login-section" style="max-width: 600px; margin: 8px;">
<div class="title-bar">
<div class="title-bar-text">Login to Spotify</div>
</div>
<div class="window-body">
<p>Enter your Spotify Developer credentials and authorize access:</p>
<div class="field-row-stacked">
<label for="client-id-input">Client ID</label>
<input
id="client-id-input"
type="text"
bind:value={clientIdInput}
placeholder="Your Spotify App Client ID"
disabled={isAuthenticating || isWaitingForCallback}
/>
</div>
<div class="field-row-stacked">
<label for="client-secret-input">Client Secret</label>
<input
id="client-secret-input"
type="password"
bind:value={clientSecretInput}
placeholder="Your Spotify App Client Secret"
disabled={isAuthenticating || isWaitingForCallback}
/>
</div>
{#if loginError}
<div class="error-message">
{loginError}
</div>
{/if}
{#if isWaitingForCallback}
<div class="info-message">
Waiting for authorization in your browser... Please complete the login process.
</div>
{/if}
<div class="button-row">
<button onclick={handleAuthorize} disabled={isAuthenticating || isWaitingForCallback}>
{isAuthenticating ? 'Authorizing...' : 'Authorize with Spotify'}
</button>
</div>
<p style="margin-top: 8px; font-size: 11px; opacity: 0.7;">
This will open Spotify's login page in your default browser.
</p>
<details class="instructions">
<summary>How to get your Spotify Developer credentials</summary>
<div class="instructions-content">
<ol>
<li>Go to <strong>developer.spotify.com/dashboard</strong></li>
<li>Log in with your Spotify account</li>
<li>Click <strong>"Create app"</strong></li>
<li>Fill in the app details:
<ul>
<li>App name: (any name you want, e.g., "Shark Music Player")</li>
<li>App description: (any description)</li>
<li>Redirect URI: <code>http://127.0.0.1:8228/callback</code></li>
<li>Check the Web API box</li>
</ul>
</li>
<li>Click <strong>"Save"</strong></li>
<li>Click <strong>"Settings"</strong> on your new app</li>
<li>Copy the <strong>Client ID</strong> (visible by default)</li>
<li>Click <strong>"View client secret"</strong> and copy the <strong>Client Secret</strong></li>
<li>Paste both values into the fields above</li>
</ol>
<p><strong>Note:</strong> The Client ID and Client Secret are used to authenticate your app with Spotify. Keep the Client Secret private and never share it publicly.</p>
<p><strong>Important:</strong> The Redirect URI must be exactly <code>http://127.0.0.1:8228/callback</code>. Port 8228 must be available when authorizing. If you get a port error, close any application using port 8228.</p>
<p><strong>Scopes used:</strong> This app requests access to your profile, email, saved library (tracks, albums), playlists (including private and collaborative), and followed artists.</p>
</div>
</details>
</div>
</section>
{:else if loading}
<p style="padding: 8px;">Loading favorites...</p>
{:else}
<section class="favorites-content">
<!-- Tabs -->
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
-->
<menu role="tablist">
<li role="tab" aria-selected={viewMode === 'playlists'}>
<button onclick={() => viewMode = 'playlists'}>Playlists</button>
</li>
<li role="tab" aria-selected={viewMode === 'tracks'}>
<button onclick={() => viewMode = 'tracks'}>Tracks</button>
</li>
<li role="tab" aria-selected={viewMode === 'artists'}>
<button onclick={() => viewMode = 'artists'}>Artists</button>
</li>
<li role="tab" aria-selected={viewMode === 'albums'}>
<button onclick={() => viewMode = 'albums'}>Albums</button>
</li>
<li role="tab" aria-selected={viewMode === 'info'}>
<button onclick={() => viewMode = 'info'}>Info</button>
</li>
</menu>
<!-- Tab Content -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if syncing}
<div class="sync-status">
<p>Refreshing favorites from Spotify...</p>
</div>
{:else if viewMode === 'playlists'}
<!-- Playlists View -->
<div class="tab-header">
<h4>Favorite Playlists</h4>
<button onclick={refreshFavorites} disabled={syncing}>
Refresh
</button>
</div>
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Playlist</th>
<th>Tracks</th>
</tr>
</thead>
<tbody>
<!-- Virtual Spotify Likes Playlist (only show if we have favorite tracks) -->
{#if tracks.length > 0}
<tr
class:highlighted={selectedIndex === -1}
class="favorite-tracks-row"
onclick={() => handleItemClick(-1)}
ondblclick={() => handlePlaylistDoubleClick('spotify-likes')}
>
<td>Spotify Likes</td>
<td>{tracks.length}</td>
</tr>
{/if}
<!-- User Playlists -->
{#each playlists as playlist, i}
<tr
class:highlighted={selectedIndex === i}
onclick={() => handleItemClick(i)}
ondblclick={() => handlePlaylistDoubleClick(playlist.id)}
>
<td>{playlist.name}</td>
<td>{playlist.track_count}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'tracks'}
<!-- Tracks View -->
<div class="tab-header">
<h4>Favorite Tracks</h4>
<button onclick={refreshFavorites} disabled={syncing}>
Refresh
</button>
</div>
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{#each tracks as track, i}
<tr
class:highlighted={selectedIndex === i}
onclick={() => handleItemClick(i)}
>
<td>{track.name}</td>
<td>{track.artist_name}</td>
<td>{track.album_name}</td>
<td class="duration">{formatDuration(track.duration_ms)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'artists'}
<!-- Artists View -->
<div class="tab-header">
<h4>Followed Artists</h4>
<button onclick={refreshFavorites} disabled={syncing}>
Refresh
</button>
</div>
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Artist</th>
<th>Followers</th>
</tr>
</thead>
<tbody>
{#each artists as artist, i}
<tr
class:highlighted={selectedIndex === i}
onclick={() => handleItemClick(i)}
>
<td>{artist.name}</td>
<td>{artist.followers.toLocaleString()}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'albums'}
<!-- Albums View -->
<div class="tab-header">
<h4>Saved Albums</h4>
<button onclick={refreshFavorites} disabled={syncing}>
Refresh
</button>
</div>
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Album</th>
<th>Artist</th>
<th>Year</th>
</tr>
</thead>
<tbody>
{#each albums as album, i}
<tr
class:highlighted={selectedIndex === i}
onclick={() => handleItemClick(i)}
>
<td>{album.name}</td>
<td>{album.artist_name}</td>
<td>{album.release_date ? new Date(album.release_date).getFullYear() : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'info'}
<!-- User Info View -->
<div class="user-container">
{#if error}
<fieldset>
<legend>Error</legend>
<div class="error-message">
{error}
</div>
</fieldset>
{/if}
<fieldset>
<legend>User Information</legend>
<div class="field-row">
<span class="field-label">Name:</span>
<span>{$spotifyAuth.user?.display_name || 'Unknown'}</span>
</div>
<div class="field-row">
<span class="field-label">Email:</span>
<span>{$spotifyAuth.user?.email || 'N/A'}</span>
</div>
<div class="field-row">
<span class="field-label">Country:</span>
<span>{$spotifyAuth.user?.country || 'N/A'}</span>
</div>
<div class="field-row">
<span class="field-label">Subscription:</span>
<span>{$spotifyAuth.user?.product ? $spotifyAuth.user.product.toUpperCase() : 'Unknown'}</span>
</div>
</fieldset>
{#if userRefreshMessage}
<div class="message-box">
{userRefreshMessage}
</div>
{/if}
<fieldset style="margin-top: 16px;">
<legend>Actions</legend>
<div class="button-row">
<button onclick={handleRefreshUser} disabled={refreshingUser}>
{refreshingUser ? 'Refreshing...' : 'Refresh User Info'}
</button>
<button onclick={refreshFavorites} disabled={syncing}>
{syncing ? 'Refreshing...' : 'Refresh Cache'}
</button>
<button onclick={handleLogout}>Logout</button>
</div>
</fieldset>
</div>
{/if}
</div>
</div>
</section>
{/if}
</div>
<style>
.spotify-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
h2 {
margin: 0;
}
.login-section {
margin-bottom: 16px;
}
.favorites-content {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-content {
margin-top: -2px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-content .window-body {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--button-shadow, #808080);
}
.tab-header h4 {
margin: 0;
}
.table-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
table {
width: 100%;
}
th {
text-align: left;
}
.duration {
font-family: monospace;
font-size: 0.9em;
text-align: center;
width: 80px;
}
.user-container {
padding: 16px;
}
.message-box {
padding: 8px;
margin: 8px 0;
background-color: var(--button-shadow, #2a2a2a);
border: 1px solid var(--button-highlight, #606060);
}
.sync-status {
padding: 16px 8px;
text-align: center;
}
.favorite-tracks-row {
font-weight: bold;
}
.window-body {
padding: 12px;
}
.field-row-stacked {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.field-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.field-label {
font-weight: bold;
min-width: 120px;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 4px;
}
.button-row {
margin-top: 12px;
display: flex;
gap: 8px;
}
.error-message {
background-color: #ffcccc;
color: #cc0000;
padding: 8px;
margin: 8px 0;
border: 1px solid #cc0000;
}
.info-message {
background-color: #cce5ff;
color: #004085;
padding: 8px;
margin: 8px 0;
border: 1px solid #004085;
}
.instructions {
margin-top: 16px;
padding: 8px;
background-color: var(--button-shadow, #2a2a2a);
}
.instructions summary {
cursor: pointer;
font-weight: bold;
user-select: none;
}
.instructions-content {
margin-top: 8px;
padding-left: 4px;
}
.instructions-content ol {
margin: 8px 0;
padding-left: 20px;
}
.instructions-content ul {
margin: 4px 0;
padding-left: 20px;
}
.instructions-content li {
margin: 6px 0;
line-height: 1.4;
}
.instructions-content strong {
font-weight: bold;
}
.instructions-content code {
background-color: var(--button-highlight, #505050);
padding: 2px 4px;
border-radius: 2px;
font-family: monospace;
font-size: 0.9em;
}
.instructions-content p {
margin: 8px 0;
line-height: 1.5;
}

View File

@@ -0,0 +1,307 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { spotifyAuth } from '$lib/stores/spotify';
import { spotifyAPI } from '$lib/services/spotify';
import {
getCachedPlaylist,
getCachedPlaylistTracks,
getCachedTracks,
upsertPlaylistTracks,
type SpotifyPlaylist,
type SpotifyPlaylistTrack,
type SpotifyTrack
} from '$lib/library/spotify-database';
import SpotifyCollectionView from '$lib/components/SpotifyCollectionView.svelte';
import type { Track, AudioFormat } from '$lib/types/track';
import { addSpotifyTrackToQueue } from '$lib/services/spotify/addToQueue';
import { downloadSpotifyPlaylist } from '$lib/services/spotify/playlistDownloader';
import { settings } from '$lib/stores/settings';
import { deezerAuth } from '$lib/stores/deezer';
import { setError } from '$lib/stores/status';
let playlistId = $derived($page.params.id!);
let loading = $state(true);
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>());
// Convert Spotify tracks to Track type for CollectionView
let tracks = $derived<Track[]>(
playlistTracks.map((track, i) => ({
path: '',
filename: '',
format: 'unknown' as AudioFormat,
spotifyId: track.track_id, // Store Spotify ID for downloading
metadata: {
title: track.name || 'Unknown Title',
artist: track.artist_name || 'Unknown Artist',
album: track.album_name || 'Unknown Album',
trackNumber: track.track_number ?? i + 1,
duration: Math.floor(track.duration_ms / 1000)
}
}))
);
onMount(async () => {
await loadPlaylist();
});
async function loadPlaylist() {
loading = true;
error = null;
try {
if (playlistId === 'spotify-likes') {
// Special case: load all favorite tracks as a virtual playlist
await loadSpotifyLikes();
} else {
// Load regular playlist
await loadRegularPlaylist();
}
} catch (e) {
console.error('Error loading playlist:', e);
error = 'Error loading playlist: ' + (e instanceof Error ? e.message : String(e));
} finally {
loading = false;
}
}
async function loadSpotifyLikes() {
const allTracks = await getCachedTracks();
// Create virtual playlist object
playlist = {
id: 'spotify-likes',
name: 'Spotify Likes',
track_count: allTracks.length,
owner_name: $spotifyAuth.user!.display_name,
cached_at: Math.floor(Date.now() / 1000)
};
// Convert SpotifyTrack[] to SpotifyPlaylistTrack[]
playlistTracks = allTracks.map((track, i) => ({
id: i,
playlist_id: 'spotify-likes',
track_id: track.id,
name: track.name,
artist_name: track.artist_name,
album_name: track.album_name,
duration_ms: track.duration_ms,
track_number: i + 1,
isrc: track.isrc,
cached_at: track.cached_at
}));
// Set cover art from first track's album
if (allTracks.length > 0) {
if (allTracks[0].album_image_url) {
coverImageUrl = allTracks[0].album_image_url;
} else if ($spotifyAuth.accessToken && $spotifyAuth.clientId && $spotifyAuth.clientSecret && $spotifyAuth.refreshToken) {
try {
spotifyAPI.setClientCredentials($spotifyAuth.clientId, $spotifyAuth.clientSecret);
spotifyAPI.setTokens(
$spotifyAuth.accessToken,
$spotifyAuth.refreshToken,
$spotifyAuth.expiresAt!
);
const trackData = await spotifyAPI.apiCall<any>(`/tracks/${allTracks[0].id}`);
const albumImageUrl = trackData.album?.images?.[0]?.url;
if (albumImageUrl) {
coverImageUrl = albumImageUrl;
const database = await import('$lib/library/spotify-database').then(m => m.initSpotifyDatabase());
await database.execute(
'UPDATE spotify_tracks SET album_image_url = $1 WHERE id = $2',
[albumImageUrl, allTracks[0].id]
);
}
} catch (err) {
console.error('Failed to fetch album cover for Spotify Likes:', err);
}
}
}
}
async function loadRegularPlaylist() {
// Load playlist metadata
const cachedPlaylist = await getCachedPlaylist(playlistId);
if (!cachedPlaylist) {
// Playlist not in cache, try to fetch from API
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret || !$spotifyAuth.refreshToken) {
error = 'Not logged in to Spotify';
return;
}
try {
spotifyAPI.setClientCredentials($spotifyAuth.clientId, $spotifyAuth.clientSecret);
spotifyAPI.setTokens(
$spotifyAuth.accessToken,
$spotifyAuth.refreshToken,
$spotifyAuth.expiresAt!
);
const apiPlaylist = await spotifyAPI.getPlaylist(playlistId);
// Create playlist object from API response
playlist = {
id: apiPlaylist.id,
name: apiPlaylist.name,
track_count: apiPlaylist.tracks?.total || 0,
owner_name: apiPlaylist.owner?.display_name || 'Unknown',
image_url: apiPlaylist.images?.[0]?.url,
cached_at: Math.floor(Date.now() / 1000)
};
coverImageUrl = playlist.image_url;
// Fetch and cache tracks
const apiTracks = await spotifyAPI.getPlaylistTracks(playlistId);
await upsertPlaylistTracks(playlistId, apiTracks);
} catch (e) {
error = 'Playlist not found and could not be fetched from Spotify';
return;
}
} else {
playlist = cachedPlaylist;
coverImageUrl = playlist.image_url;
}
// Load tracks
const cachedTracks = await getCachedPlaylistTracks(playlistId);
if (cachedTracks.length === 0 && playlist.track_count > 0) {
// Tracks not in cache, fetch from API
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret || !$spotifyAuth.refreshToken) {
error = 'Cannot load tracks: Not logged in to Spotify';
return;
}
try {
spotifyAPI.setClientCredentials($spotifyAuth.clientId, $spotifyAuth.clientSecret);
spotifyAPI.setTokens(
$spotifyAuth.accessToken,
$spotifyAuth.refreshToken,
$spotifyAuth.expiresAt!
);
const apiTracks = await spotifyAPI.getPlaylistTracks(playlistId);
await upsertPlaylistTracks(playlistId, apiTracks);
// Reload from cache
playlistTracks = await getCachedPlaylistTracks(playlistId);
} catch (e) {
error = 'Error fetching playlist tracks from Spotify';
}
} else {
playlistTracks = cachedTracks;
}
}
function handleTrackClick(index: number) {
selectedTrackIndex = index;
}
async function handleDownloadTrack(index: number) {
if (!$deezerAuth.loggedIn) {
setError('Deezer login required for downloads');
return;
}
const spotifyTrack = playlistTracks[index];
if (!spotifyTrack) return;
// Mark as downloading
downloadingTrackIds = new Set(downloadingTrackIds).add(spotifyTrack.track_id);
try {
await addSpotifyTrackToQueue({
id: spotifyTrack.track_id,
name: spotifyTrack.name,
artist_name: spotifyTrack.artist_name,
album_name: spotifyTrack.album_name,
duration_ms: spotifyTrack.duration_ms,
isrc: spotifyTrack.isrc
});
} catch (error) {
console.error('Error downloading track:', error);
} finally {
// Remove from downloading set
const newSet = new Set(downloadingTrackIds);
newSet.delete(spotifyTrack.track_id);
downloadingTrackIds = newSet;
}
}
async function handleDownloadPlaylist() {
if (!$deezerAuth.loggedIn) {
setError('Deezer login required for downloads');
return;
}
if (!playlist || !$settings.musicFolder || !$settings.playlistsFolder) {
setError('Please configure music and playlists folders in settings');
return;
}
try {
await downloadSpotifyPlaylist(
playlist.name,
playlistTracks,
$settings.playlistsFolder,
$settings.musicFolder
);
} catch (error) {
console.error('Error downloading playlist:', error);
setError(
'Error downloading playlist: ' + (error instanceof Error ? error.message : String(error))
);
}
}
</script>
{#if loading}
<div class="wrapper">
<p style="padding: 8px;">Loading playlist...</p>
</div>
{:else if error}
<div class="wrapper">
<div class="window" style="margin: 8px;">
<div class="title-bar">
<div class="title-bar-text">Error</div>
</div>
<div class="window-body">
<p>{error}</p>
</div>
</div>
</div>
{:else if playlist}
<div class="wrapper">
<SpotifyCollectionView
title={playlist.name}
subtitle={playlist.owner_name}
metadata="{playlist.track_count} tracks"
{coverImageUrl}
{tracks}
{selectedTrackIndex}
{downloadingTrackIds}
onTrackClick={handleTrackClick}
onDownloadTrack={handleDownloadTrack}
onDownloadPlaylist={handleDownloadPlaylist}
/>
</div>
{/if}
<style>
.wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,608 @@
<script lang="ts">
import { onMount } from 'svelte';
import { open } from '@tauri-apps/plugin-dialog';
import { exists } from '@tauri-apps/plugin-fs';
import { settings } from '$lib/stores/settings';
import {
deviceSyncSettings,
loadDeviceSyncSettings,
setMusicPath,
setPlaylistsPath,
setOverwriteMode,
type OverwriteMode
} from '$lib/stores/deviceSync';
import {
indexAndCompare,
syncToDevice,
formatBytes,
type SyncDiff,
type SyncProgress
} from '$lib/services/deviceSync';
type ViewMode = 'sync' | 'preferences';
let viewMode = $state<ViewMode>('sync');
let configured = $state(false);
let deviceConnected = $state(false);
let checkingConnection = $state(false);
// Path inputs for initial setup
let musicPathInput = $state('');
let playlistsPathInput = $state('');
// Sync state
let indexing = $state(false);
let syncing = $state(false);
let syncDiff = $state<SyncDiff | null>(null);
let syncProgress = $state<SyncProgress | null>(null);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let selectedFileIndex = $state<number | null>(null);
onMount(async () => {
await loadDeviceSyncSettings();
configured = !!$deviceSyncSettings.musicPath;
if (configured) {
await checkDeviceConnection();
}
});
async function checkDeviceConnection() {
if (!$deviceSyncSettings.musicPath) {
deviceConnected = false;
return;
}
checkingConnection = true;
try {
deviceConnected = await exists($deviceSyncSettings.musicPath);
} catch (e) {
deviceConnected = false;
} finally {
checkingConnection = false;
}
}
async function handleBrowseMusicPath() {
const selected = await open({
directory: true,
multiple: false,
title: 'Select Device Music Folder'
});
if (selected && typeof selected === 'string') {
musicPathInput = selected;
}
}
async function handleBrowsePlaylistsPath() {
const selected = await open({
directory: true,
multiple: false,
title: 'Select Device Playlists Folder'
});
if (selected && typeof selected === 'string') {
playlistsPathInput = selected;
}
}
async function handleSaveConfiguration() {
if (!musicPathInput) {
error = 'Please select a music folder path';
return;
}
error = null;
await setMusicPath(musicPathInput);
await setPlaylistsPath(playlistsPathInput || null);
configured = true;
await checkDeviceConnection();
}
async function handleIndexAndCompare() {
if (!$settings.musicFolder) {
error = 'No library music folder configured. Please set one in Settings.';
return;
}
if (!$deviceSyncSettings.musicPath) {
error = 'No device music path configured.';
return;
}
indexing = true;
error = null;
successMessage = null;
syncDiff = null;
try {
await checkDeviceConnection();
if (!deviceConnected) {
throw new Error('Device is not connected or path does not exist');
}
const result = await indexAndCompare(
$settings.musicFolder,
$deviceSyncSettings.musicPath,
$deviceSyncSettings.overwriteMode
);
syncDiff = result;
successMessage = `Found ${result.filesToCopy.length} files to sync`;
} catch (e) {
error = 'Error indexing device: ' + (e instanceof Error ? e.message : String(e));
syncDiff = null;
} finally {
indexing = false;
}
}
async function handleSync() {
if (!syncDiff || syncDiff.filesToCopy.length === 0) {
error = 'No files to sync. Run Index & Compare first.';
return;
}
if (!$settings.musicFolder || !$deviceSyncSettings.musicPath) {
error = 'Configuration error';
return;
}
syncing = true;
error = null;
successMessage = null;
syncProgress = null;
try {
const result = await syncToDevice(
$settings.musicFolder,
$deviceSyncSettings.musicPath,
syncDiff.filesToCopy,
(progress) => {
syncProgress = progress;
}
);
successMessage = result;
syncDiff = null;
} catch (e) {
error = 'Error syncing to device: ' + (e instanceof Error ? e.message : String(e));
} finally {
syncing = false;
syncProgress = null;
}
}
async function handleUpdateMusicPath() {
const selected = await open({
directory: true,
multiple: false,
title: 'Select Device Music Folder'
});
if (selected && typeof selected === 'string') {
await setMusicPath(selected);
await checkDeviceConnection();
}
}
async function handleUpdatePlaylistsPath() {
const selected = await open({
directory: true,
multiple: false,
title: 'Select Device Playlists Folder'
});
if (selected && typeof selected === 'string') {
await setPlaylistsPath(selected);
}
}
function handleFileClick(index: number) {
selectedFileIndex = index;
}
</script>
<div class="sync-wrapper">
<h2 style="padding: 8px">Device Sync</h2>
{#if !configured}
<!-- Initial Configuration -->
<section class="window config-section" style="max-width: 600px; margin: 8px;">
<div class="title-bar">
<div class="title-bar-text">Configure Device Paths</div>
</div>
<div class="window-body" style="padding: 12px;">
<p>Select the folders on your portable device to sync music and playlists:</p>
<div class="field-row-stacked">
<label for="music-path-input">Device Music Folder</label>
<div class="input-with-button">
<input
id="music-path-input"
type="text"
bind:value={musicPathInput}
placeholder="/Volumes/iPod/Music"
readonly
/>
<button onclick={handleBrowseMusicPath}>Browse...</button>
</div>
</div>
<div class="field-row-stacked">
<label for="playlists-path-input">Device Playlists Folder</label>
<div class="input-with-button">
<input
id="playlists-path-input"
type="text"
bind:value={playlistsPathInput}
placeholder="/Volumes/iPod/Playlists"
readonly
/>
<button onclick={handleBrowsePlaylistsPath}>Browse...</button>
</div>
</div>
{#if error}
<p class="error">{error}</p>
{/if}
<div class="button-row">
<button onclick={handleSaveConfiguration}>Save Configuration</button>
</div>
</div>
</section>
{:else if syncing}
<div class="sync-status">
{#if syncProgress && syncProgress.total > 0}
<p class="progress-text">{syncProgress.current} / {syncProgress.total} files</p>
<div class="progress-bar">
<div
class="progress-fill"
style="width: {(syncProgress.current / syncProgress.total) * 100}%"
></div>
</div>
{#if syncProgress.currentFile}
<p class="current-file">{syncProgress.currentFile}</p>
{/if}
{:else}
<p>Syncing...</p>
{/if}
</div>
{:else if indexing}
<p style="padding: 8px;">Indexing device...</p>
{:else if error}
<p class="error" style="padding: 8px;">{error}</p>
{:else}
<section class="sync-content">
<!-- Tabs -->
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
-->
<menu role="tablist">
<li role="tab" aria-selected={viewMode === 'sync'}>
<button onclick={() => viewMode = 'sync'}>Sync</button>
</li>
<li role="tab" aria-selected={viewMode === 'preferences'}>
<button onclick={() => viewMode = 'preferences'}>Preferences</button>
</li>
</menu>
<!-- Tab Content -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if viewMode === 'sync'}
<!-- Sync Tab -->
<div class="sync-info">
<p>
<strong>Device:</strong>
{#if checkingConnection}
Checking...
{:else if deviceConnected}
Connected - {$deviceSyncSettings.musicPath}
{:else}
Not Connected - {$deviceSyncSettings.musicPath}
{/if}
</p>
<div class="button-group">
<button
onclick={handleIndexAndCompare}
disabled={indexing || syncing || !deviceConnected}
>
{indexing ? 'Indexing...' : 'Index & Compare'}
</button>
<button
onclick={handleSync}
disabled={!syncDiff || syncDiff.filesToCopy.length === 0 || syncing || !deviceConnected}
>
{syncing ? 'Syncing...' : 'Sync to Device'}
</button>
</div>
{#if successMessage}
<p class="success">{successMessage}</p>
{/if}
{#if syncDiff}
<p>
New: {syncDiff.stats.newFiles} | Updated: {syncDiff.stats.updatedFiles} |
Unchanged: {syncDiff.stats.unchangedFiles} |
Total: {formatBytes(syncDiff.stats.totalSize)}
</p>
{/if}
</div>
{#if syncDiff && syncDiff.filesToCopy.length > 0}
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>File</th>
<th>Status</th>
<th>Size</th>
</tr>
</thead>
<tbody>
{#each syncDiff.filesToCopy as file, i}
<tr
class:highlighted={selectedFileIndex === i}
onclick={() => handleFileClick(i)}
>
<td>{file.relativePath}</td>
<td>{file.status}</td>
<td class="size-cell">{formatBytes(file.size)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{:else if viewMode === 'preferences'}
<!-- Preferences Tab -->
<div class="prefs-container">
<fieldset>
<legend>Device Paths</legend>
<div class="field-row-stacked">
<div class="field-label">Device Music Path</div>
<div class="path-display">
<code>{$deviceSyncSettings.musicPath || 'Not set'}</code>
<button onclick={handleUpdateMusicPath}>Change...</button>
</div>
</div>
<div class="field-row-stacked">
<div class="field-label">Device Playlists Path</div>
<div class="path-display">
<code>{$deviceSyncSettings.playlistsPath || 'Not set'}</code>
<button onclick={handleUpdatePlaylistsPath}>Change...</button>
</div>
</div>
</fieldset>
<fieldset style="margin-top: 16px;">
<legend>When file exists on device</legend>
<div class="field-row">
<input
type="radio"
id="mode-skip"
name="overwrite-mode"
value="skip"
bind:group={$deviceSyncSettings.overwriteMode}
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
/>
<label for="mode-skip">Skip (don't overwrite)</label>
</div>
<div class="field-row">
<input
type="radio"
id="mode-different"
name="overwrite-mode"
value="different"
bind:group={$deviceSyncSettings.overwriteMode}
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
/>
<label for="mode-different">Overwrite if different size</label>
</div>
<div class="field-row">
<input
type="radio"
id="mode-always"
name="overwrite-mode"
value="always"
bind:group={$deviceSyncSettings.overwriteMode}
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
/>
<label for="mode-always">Always overwrite</label>
</div>
<p class="help-text">
System files and _temp folders are always skipped.
</p>
</fieldset>
</div>
{/if}
</div>
</div>
</section>
{/if}
</div>
<style>
.sync-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
h2 {
margin: 0;
flex-shrink: 0;
}
.sync-status {
padding: 16px 8px;
}
.sync-status p {
margin: 0 0 8px 0;
}
.progress-bar {
width: 100%;
height: 20px;
background: #c0c0c0;
border: 2px inset #808080;
margin-bottom: 4px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #000080, #0000ff);
transition: width 0.2s ease;
}
.progress-text {
font-size: 12px;
color: #808080;
}
.current-file {
font-size: 11px;
font-family: monospace;
color: #808080;
margin: 4px 0 0 0;
word-break: break-all;
}
.config-section {
margin-bottom: 16px;
}
.field-row-stacked {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.input-with-button {
display: flex;
gap: 8px;
}
.input-with-button input {
flex: 1;
padding: 4px;
}
.field-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: center;
}
.button-row {
margin-top: 12px;
display: flex;
gap: 8px;
}
.button-group {
display: flex;
gap: 8px;
margin: 8px 0;
}
.error {
color: #ff6b6b;
}
.success {
color: #808080;
}
.sync-content {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-content {
margin-top: -2px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.window-body {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.sync-info {
padding: 16px 8px;
flex-shrink: 0;
}
.sync-info p {
margin: 0 0 8px 0;
}
.table-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
table {
width: 100%;
}
th {
text-align: left;
}
.size-cell {
text-align: right;
font-family: monospace;
font-size: 0.9em;
width: 100px;
}
.prefs-container {
padding: 16px;
overflow-y: auto;
}
.path-display {
display: flex;
gap: 8px;
align-items: center;
}
.path-display code {
flex: 1;
padding: 4px 8px;
background: var(--button-shadow, #2a2a2a);
border: 1px solid var(--button-highlight, #606060);
font-family: monospace;
font-size: 0.9em;
}
.help-text {
margin: 8px 0 0 0;
font-size: 11px;
color: #808080;
}
</style>

44
static/icons/ipod.svg Normal file
View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
<defs>
<style>
.cls-1 {
fill: url(#linear-gradient-2);
}
.cls-2 {
fill: #231f20;
}
.cls-3 {
fill: url(#linear-gradient);
}
.cls-4 {
fill: #bfbebd;
}
.cls-5 {
fill: #c0c1c4;
}
.cls-6 {
fill: #fefefe;
}
</style>
<linearGradient id="linear-gradient" x1="309.936" y1="731.362" x2="309.936" y2="731.454" gradientTransform="translate(0 1024) scale(1 -1)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#595858"/>
<stop offset="1" stop-color="#aeadad"/>
</linearGradient>
<linearGradient id="linear-gradient-2" x1="512" y1="920.713" x2="512" y2="103.287" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#a5a7aa"/>
<stop offset="1" stop-color="#e9e9ea"/>
</linearGradient>
</defs>
<path class="cls-3" d="M309.99,292.52l-.155.073.2.046c-.018-.036-.027-.073-.045-.118Z"/>
<path class="cls-1" d="M766.12,806.626l-.209-95.041-.436-302.141-.055-103.741-.045-30.874c-.009-4.009-.245-12.701.145-16.473.7-16.128-.164-33.338.018-49.566.191-16.819.609-34.02-.1-50.82-.673-16.401-6.591-30.229-19.028-41.12-17.492-15.31-39.965-13.573-61.621-13.437l-40.729.227-135.77.273-133.67-.291-42.711-.318c-24.474-.064-43.62-.5-61.157,20.028-13.946,16.328-13.028,36.947-13.019,57.103.009,13.301-.264,26.601.491,39.902.355,5.982-.282,12.064.209,18.146-.745,10.573-.1,28.701-.064,39.938l.091,74.158.027,240.411-.518,190.581-.136,52.684c-.045,10.273-.518,24.583.491,34.538,1.227,11.319,5.964,21.974,13.546,30.465,21.201,24.138,47.902,19.037,76.667,18.946l52.521-.009,146.498-.109,99.577.136,41.375.045c21.537.018,41.029,1.364,58.621-14.182,14.119-12.473,18.192-27.01,18.855-45.375.609-17.855.209-36.183.136-54.084ZM309.836,292.593l.155-.073c.018.045.027.082.045.118l-.2-.046ZM712.382,288.911l-.118,101.296.018,33.492c.009,7.564.136,14.964-.527,22.365-.836,9.182-5.873,19.865-16.31,20.301-18.219.764-36.829.627-55.066.7l-105.705.127-131.906-.2-40.647-.036c-45.584.027-50.83,4.391-50.993-44.775l-.073-31.32-.064-108.896.082-37.874c-.009-6.018.182-13.992-.227-19.846l-.036-.582c.209-12.601.655-25.183.473-37.911-.164-11.546,1.764-27.483,16.51-28.592,4.946-.373,10.055-.318,15.046-.373l27.738-.2,84.149-.064c58.994-.2,117.978-.091,176.972.318,17.946.136,35.911-.082,53.866.155,3.273.045,7.219.091,10.419.518,17.028,2.236,16.073,21.292,16.137,33.811l.091,28.565c.018,4.173-.145,12.437.336,16.346-.773,15.701-.155,36.684-.164,52.675Z"/>
<path class="cls-4" d="M310.036,292.639l-.2-.046.155-.073c.018.045.027.082.045.118Z"/>
<path class="cls-6" d="M509.217,539.412c80.027-1.511,146.156,62.085,147.769,142.111,1.613,80.026-61.899,146.235-141.923,147.95-80.168,1.717-146.522-61.933-148.139-142.104-1.616-80.17,62.119-146.443,142.292-147.958Z"/>
<path class="cls-5" d="M509.43,630.294c29.875-1.416,55.245,21.646,56.675,51.521,1.429,29.874-21.622,55.254-51.495,56.698-29.893,1.445-55.293-21.625-56.724-51.518-1.431-29.894,21.65-55.283,51.544-56.701Z"/>
<path class="cls-2" d="M712.705,288.911l-.118,101.296.018,33.492c.009,7.564.136,14.964-.527,22.365-.836,9.182-5.873,19.865-16.31,20.301-18.219.764-36.829.627-55.066.7l-105.705.127-131.906-.2-40.647-.036c-45.584.027-50.83,4.391-50.993-44.775l-.073-31.32-.064-108.896.082-37.874c-.009-6.018.182-13.992-.227-19.846l-.036-.582c.209-12.601.655-25.183.473-37.911-.164-11.546,1.764-27.483,16.51-28.592,4.946-.373,10.055-.318,15.046-.373l27.738-.2,84.149-.064c58.994-.2,117.978-.091,176.972.318,17.946.136,35.911-.082,53.866.155,3.273.045,7.219.091,10.419.518,17.028,2.236,16.073,21.292,16.137,33.811l.091,28.565c.018,4.173-.145,12.437.336,16.346-.773,15.701-.155,36.684-.164,52.675Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB