mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
Compare commits
6 Commits
e19c25e94b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d8df1eb48 | |||
| 085f58e40f | |||
| 72bc53e495 | |||
| 651d87af4c | |||
| df4967dd55 | |||
| 1bffafad44 |
@@ -299,6 +299,74 @@ pub fn run() {
|
|||||||
kind: MigrationKind::Up,
|
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()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_oauth::init())
|
.plugin(tauri_plugin_oauth::init())
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
@@ -307,6 +375,7 @@ pub fn run() {
|
|||||||
tauri_plugin_sql::Builder::new()
|
tauri_plugin_sql::Builder::new()
|
||||||
.add_migrations("sqlite:library.db", library_migrations)
|
.add_migrations("sqlite:library.db", library_migrations)
|
||||||
.add_migrations("sqlite:deezer.db", deezer_migrations)
|
.add_migrations("sqlite:deezer.db", deezer_migrations)
|
||||||
|
.add_migrations("sqlite:spotify.db", spotify_migrations)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
|
|||||||
@@ -280,7 +280,7 @@
|
|||||||
|
|
||||||
.track-number {
|
.track-number {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.6;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duration {
|
.duration {
|
||||||
|
|||||||
324
src/lib/components/SpotifyCollectionView.svelte
Normal file
324
src/lib/components/SpotifyCollectionView.svelte
Normal 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>
|
||||||
@@ -115,7 +115,7 @@ export async function getCachedArtists(): Promise<DeezerArtist[]> {
|
|||||||
export async function getCachedTracks(): Promise<DeezerTrack[]> {
|
export async function getCachedTracks(): Promise<DeezerTrack[]> {
|
||||||
const database = await initDeezerDatabase();
|
const database = await initDeezerDatabase();
|
||||||
const tracks = await database.select<DeezerTrack[]>(
|
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 || [];
|
return tracks || [];
|
||||||
}
|
}
|
||||||
|
|||||||
343
src/lib/library/spotify-database.ts
Normal file
343
src/lib/library/spotify-database.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fetch } from '@tauri-apps/plugin-http';
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
import type { SpotifyUser } from '$lib/stores/spotify';
|
import type { SpotifyUser } from '$lib/stores/spotify';
|
||||||
import { isTokenExpired } from '$lib/stores/spotify';
|
import { isTokenExpired, saveTokens } from '$lib/stores/spotify';
|
||||||
|
|
||||||
const SPOTIFY_AUTH_URL = 'https://accounts.spotify.com/authorize';
|
const SPOTIFY_AUTH_URL = 'https://accounts.spotify.com/authorize';
|
||||||
const SPOTIFY_TOKEN_URL = 'https://accounts.spotify.com/api/token';
|
const SPOTIFY_TOKEN_URL = 'https://accounts.spotify.com/api/token';
|
||||||
@@ -142,22 +142,20 @@ export class SpotifyAPI {
|
|||||||
* Refresh the access token using the refresh token
|
* Refresh the access token using the refresh token
|
||||||
*/
|
*/
|
||||||
async refreshAccessToken(): Promise<{ access_token: string; expires_in: number }> {
|
async refreshAccessToken(): Promise<{ access_token: string; expires_in: number }> {
|
||||||
if (!this.refreshToken || !this.clientId || !this.clientSecret) {
|
if (!this.refreshToken || !this.clientId) {
|
||||||
throw new Error('Missing refresh token or client credentials');
|
throw new Error('Missing refresh token or client ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = btoa(`${this.clientId}:${this.clientSecret}`);
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
refresh_token: this.refreshToken
|
refresh_token: this.refreshToken,
|
||||||
|
client_id: this.clientId
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(SPOTIFY_TOKEN_URL, {
|
const response = await fetch(SPOTIFY_TOKEN_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
'Authorization': `Basic ${credentials}`
|
|
||||||
},
|
},
|
||||||
body: params.toString()
|
body: params.toString()
|
||||||
});
|
});
|
||||||
@@ -165,6 +163,18 @@ export class SpotifyAPI {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
console.error('Token refresh error:', errorText);
|
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}`);
|
throw new Error(`Token refresh failed: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,10 +185,14 @@ export class SpotifyAPI {
|
|||||||
this.expiresAt = Date.now() + (data.expires_in * 1000);
|
this.expiresAt = Date.now() + (data.expires_in * 1000);
|
||||||
|
|
||||||
// Note: Spotify may or may not return a new refresh token
|
// Note: Spotify may or may not return a new refresh token
|
||||||
|
const refreshToken = data.refresh_token || this.refreshToken!;
|
||||||
if (data.refresh_token) {
|
if (data.refresh_token) {
|
||||||
this.refreshToken = data.refresh_token;
|
this.refreshToken = data.refresh_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save refreshed tokens to store
|
||||||
|
await saveTokens(this.accessToken!, refreshToken, data.expires_in);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
access_token: data.access_token,
|
access_token: data.access_token,
|
||||||
expires_in: data.expires_in
|
expires_in: data.expires_in
|
||||||
@@ -189,7 +203,7 @@ export class SpotifyAPI {
|
|||||||
* Make an authenticated API call to Spotify
|
* Make an authenticated API call to Spotify
|
||||||
* Automatically refreshes token if expired
|
* Automatically refreshes token if expired
|
||||||
*/
|
*/
|
||||||
private async apiCall<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
async apiCall<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
// Check if token needs refresh
|
// Check if token needs refresh
|
||||||
if (isTokenExpired(this.expiresAt)) {
|
if (isTokenExpired(this.expiresAt)) {
|
||||||
console.log('[Spotify] Token expired, refreshing...');
|
console.log('[Spotify] Token expired, refreshing...');
|
||||||
@@ -254,6 +268,143 @@ export class SpotifyAPI {
|
|||||||
const afterParam = after ? `&after=${after}` : '';
|
const afterParam = after ? `&after=${after}` : '';
|
||||||
return this.apiCall(`/me/following?type=artist&limit=${limit}${afterParam}`);
|
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 singleton instance
|
||||||
|
|||||||
216
src/lib/services/spotify/addToQueue.ts
Normal file
216
src/lib/services/spotify/addToQueue.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* Utility to add a Spotify track to the download queue by converting it to Deezer
|
||||||
|
* Uses ISRC matching to find the equivalent Deezer track
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { deezerAPI } from '../deezer';
|
||||||
|
import { addToQueue } from '$lib/stores/downloadQueue';
|
||||||
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
|
import { trackExists } from '../deezer/downloader';
|
||||||
|
import { setInfo, setWarning, setError } from '$lib/stores/status';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { convertSpotifyTrackToDeezer, type SpotifyTrackInput } from './converter';
|
||||||
|
import type { DeezerTrack } from '$lib/types/deezer';
|
||||||
|
|
||||||
|
export interface SpotifyTrackData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist_name: string;
|
||||||
|
album_name: string;
|
||||||
|
duration_ms: number;
|
||||||
|
isrc?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a Spotify track to the download queue by converting it to Deezer
|
||||||
|
* @param spotifyTrack - Spotify track data (from cache or API)
|
||||||
|
* @returns Result object with success status and details
|
||||||
|
*/
|
||||||
|
export async function addSpotifyTrackToQueue(
|
||||||
|
spotifyTrack: SpotifyTrackData
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
deezerId?: string;
|
||||||
|
matchMethod?: string;
|
||||||
|
reason?: string;
|
||||||
|
}> {
|
||||||
|
// Ensure Deezer authentication
|
||||||
|
const authState = get(deezerAuth);
|
||||||
|
if (!authState.loggedIn || !authState.arl) {
|
||||||
|
setError('Deezer login required for downloads');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reason: 'deezer_auth_required'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
deezerAPI.setArl(authState.arl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert Spotify track to Deezer
|
||||||
|
console.log(`[AddSpotifyToQueue] Converting: ${spotifyTrack.name} by ${spotifyTrack.artist_name}`);
|
||||||
|
|
||||||
|
const conversionInput: SpotifyTrackInput = {
|
||||||
|
id: spotifyTrack.id,
|
||||||
|
name: spotifyTrack.name,
|
||||||
|
artists: [spotifyTrack.artist_name],
|
||||||
|
album: spotifyTrack.album_name,
|
||||||
|
duration_ms: spotifyTrack.duration_ms,
|
||||||
|
isrc: spotifyTrack.isrc
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversionResult = await convertSpotifyTrackToDeezer(conversionInput);
|
||||||
|
|
||||||
|
if (!conversionResult.success || !conversionResult.deezerTrack) {
|
||||||
|
const errorMsg = `Could not find "${spotifyTrack.name}" on Deezer`;
|
||||||
|
console.warn(`[AddSpotifyToQueue] ${errorMsg}`);
|
||||||
|
setWarning(errorMsg);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reason: conversionResult.error || 'conversion_failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deezerPublicTrack = conversionResult.deezerTrack;
|
||||||
|
const deezerTrackId = deezerPublicTrack.id.toString();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[AddSpotifyToQueue] Matched to Deezer track: ${deezerTrackId} via ${conversionResult.matchMethod}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch full track data from Deezer GW API
|
||||||
|
const deezerFullTrack = await deezerAPI.getTrack(deezerTrackId);
|
||||||
|
|
||||||
|
if (!deezerFullTrack || !deezerFullTrack.SNG_ID) {
|
||||||
|
const errorMsg = 'Failed to fetch full Deezer track data';
|
||||||
|
console.error(`[AddSpotifyToQueue] ${errorMsg}`);
|
||||||
|
setError(errorMsg);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reason: 'deezer_fetch_failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch album data for cover art
|
||||||
|
let albumData = null;
|
||||||
|
try {
|
||||||
|
albumData = await deezerAPI.getAlbumData(deezerFullTrack.ALB_ID.toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[AddSpotifyToQueue] Could not fetch album data:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch lyrics
|
||||||
|
let lyricsData = null;
|
||||||
|
try {
|
||||||
|
lyricsData = await deezerAPI.getLyrics(deezerFullTrack.SNG_ID.toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[AddSpotifyToQueue] Could not fetch lyrics:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse lyrics if available
|
||||||
|
let lyrics = undefined;
|
||||||
|
if (lyricsData) {
|
||||||
|
let syncLrc = '';
|
||||||
|
if (lyricsData.LYRICS_SYNC_JSON) {
|
||||||
|
for (const line of lyricsData.LYRICS_SYNC_JSON) {
|
||||||
|
const text = line.line || '';
|
||||||
|
const timestamp = line.lrc_timestamp || '[00:00.00]';
|
||||||
|
syncLrc += `${timestamp}${text}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lyrics = {
|
||||||
|
sync: syncLrc || undefined,
|
||||||
|
unsync: lyricsData.LYRICS_TEXT || undefined,
|
||||||
|
syncID3: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full DeezerTrack object
|
||||||
|
const deezerTrack: DeezerTrack = {
|
||||||
|
id: parseInt(deezerFullTrack.SNG_ID, 10),
|
||||||
|
title: deezerFullTrack.SNG_TITLE,
|
||||||
|
artist: deezerFullTrack.ART_NAME,
|
||||||
|
artistId: parseInt(deezerFullTrack.ART_ID, 10),
|
||||||
|
artists: [deezerFullTrack.ART_NAME],
|
||||||
|
album: deezerFullTrack.ALB_TITLE,
|
||||||
|
albumId: parseInt(deezerFullTrack.ALB_ID, 10),
|
||||||
|
albumArtist: deezerFullTrack.ART_NAME,
|
||||||
|
albumArtistId: parseInt(deezerFullTrack.ART_ID, 10),
|
||||||
|
trackNumber:
|
||||||
|
typeof deezerFullTrack.TRACK_NUMBER === 'number'
|
||||||
|
? deezerFullTrack.TRACK_NUMBER
|
||||||
|
: parseInt(deezerFullTrack.TRACK_NUMBER, 10),
|
||||||
|
discNumber:
|
||||||
|
typeof deezerFullTrack.DISK_NUMBER === 'number'
|
||||||
|
? deezerFullTrack.DISK_NUMBER
|
||||||
|
: parseInt(deezerFullTrack.DISK_NUMBER, 10),
|
||||||
|
duration:
|
||||||
|
typeof deezerFullTrack.DURATION === 'number'
|
||||||
|
? deezerFullTrack.DURATION
|
||||||
|
: parseInt(deezerFullTrack.DURATION, 10),
|
||||||
|
explicit: deezerFullTrack.EXPLICIT_LYRICS === 1,
|
||||||
|
md5Origin: deezerFullTrack.MD5_ORIGIN,
|
||||||
|
mediaVersion: deezerFullTrack.MEDIA_VERSION,
|
||||||
|
trackToken: deezerFullTrack.TRACK_TOKEN,
|
||||||
|
// Enhanced metadata
|
||||||
|
lyrics,
|
||||||
|
albumCoverUrl: albumData?.ALB_PICTURE
|
||||||
|
? `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/500x500-000000-80-0-0.jpg`
|
||||||
|
: undefined,
|
||||||
|
albumCoverXlUrl: albumData?.ALB_PICTURE
|
||||||
|
? `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/1000x1000-000000-80-0-0.jpg`
|
||||||
|
: undefined,
|
||||||
|
label: albumData?.LABEL_NAME,
|
||||||
|
barcode: albumData?.UPC,
|
||||||
|
releaseDate: deezerFullTrack.PHYSICAL_RELEASE_DATE,
|
||||||
|
genre: deezerFullTrack.GENRE ? [deezerFullTrack.GENRE] : undefined,
|
||||||
|
copyright: deezerFullTrack.COPYRIGHT
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if we should skip this track (if it exists and overwrite is false)
|
||||||
|
const appSettings = get(settings);
|
||||||
|
|
||||||
|
if (!appSettings.deezerOverwrite && appSettings.musicFolder) {
|
||||||
|
const exists = await trackExists(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
console.log(`[AddSpotifyToQueue] Skipping "${deezerTrack.title}" - already exists`);
|
||||||
|
setWarning(`Skipped: ${deezerTrack.title} (already exists)`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
deezerId: deezerTrackId,
|
||||||
|
matchMethod: conversionResult.matchMethod,
|
||||||
|
reason: 'already_exists'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to queue
|
||||||
|
await addToQueue({
|
||||||
|
source: 'deezer',
|
||||||
|
type: 'track',
|
||||||
|
title: deezerTrack.title,
|
||||||
|
artist: deezerTrack.artist,
|
||||||
|
totalTracks: 1,
|
||||||
|
downloadObject: deezerTrack
|
||||||
|
});
|
||||||
|
|
||||||
|
setInfo(`Queued: ${deezerTrack.title}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deezerId: deezerTrackId,
|
||||||
|
matchMethod: conversionResult.matchMethod
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = `Error adding track to queue: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
console.error('[AddSpotifyToQueue]', errorMsg);
|
||||||
|
setError(errorMsg);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reason: 'queue_error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
215
src/lib/services/spotify/converter.ts
Normal file
215
src/lib/services/spotify/converter.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Spotify to Deezer track conversion utilities
|
||||||
|
* Matches Spotify tracks to Deezer tracks using ISRC codes (primary) or metadata search (fallback)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
import { deezerAPI } from '../deezer';
|
||||||
|
|
||||||
|
export interface SpotifyTrackInput {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artists: string[];
|
||||||
|
album: string;
|
||||||
|
duration_ms: number;
|
||||||
|
isrc?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeezerMatchResult {
|
||||||
|
success: boolean;
|
||||||
|
deezerTrack?: any;
|
||||||
|
matchMethod?: 'isrc' | 'metadata' | 'none';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search Deezer for a track by ISRC code
|
||||||
|
* This is the primary and most reliable matching method
|
||||||
|
*/
|
||||||
|
export async function searchDeezerByISRC(isrc: string): Promise<any | null> {
|
||||||
|
if (!isrc || isrc.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[Converter] Searching Deezer by ISRC: ${isrc}`);
|
||||||
|
|
||||||
|
const url = `https://api.deezer.com/2.0/track/isrc:${encodeURIComponent(isrc)}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
connectTimeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`[Converter] ISRC search failed with status: ${response.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Check if we got an error response
|
||||||
|
if (result.error) {
|
||||||
|
console.warn(`[Converter] ISRC search returned error:`, result.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid track found
|
||||||
|
if (result.id) {
|
||||||
|
console.log(`[Converter] Found Deezer track by ISRC: ${result.id} - ${result.title}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Converter] Error searching by ISRC:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search Deezer for a track by metadata (title + artist)
|
||||||
|
* Used as fallback when ISRC is not available or doesn't match
|
||||||
|
*/
|
||||||
|
export async function searchDeezerByMetadata(
|
||||||
|
title: string,
|
||||||
|
artist: string,
|
||||||
|
durationMs?: number
|
||||||
|
): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
// Build search query: "artist title"
|
||||||
|
const query = `${artist} ${title}`.trim();
|
||||||
|
console.log(`[Converter] Searching Deezer by metadata: "${query}"`);
|
||||||
|
|
||||||
|
const searchResults = await deezerAPI.searchTracks(query, 10);
|
||||||
|
|
||||||
|
if (!searchResults.data || searchResults.data.length === 0) {
|
||||||
|
console.warn(`[Converter] No results found for: "${query}"`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find best match
|
||||||
|
// Priority: exact title match, then duration match (±2 seconds)
|
||||||
|
const durationSec = durationMs ? Math.floor(durationMs / 1000) : undefined;
|
||||||
|
|
||||||
|
for (const track of searchResults.data) {
|
||||||
|
// Check title similarity (case-insensitive)
|
||||||
|
const titleMatch = track.title.toLowerCase() === title.toLowerCase();
|
||||||
|
|
||||||
|
// Check duration if available (within 2 seconds tolerance)
|
||||||
|
const durationMatch = !durationSec || Math.abs(track.duration - durationSec) <= 2;
|
||||||
|
|
||||||
|
if (titleMatch && durationMatch) {
|
||||||
|
console.log(`[Converter] Found exact match by metadata: ${track.id} - ${track.title}`);
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no exact match, return first result as best guess
|
||||||
|
const firstResult = searchResults.data[0];
|
||||||
|
console.log(`[Converter] Using first result as best match: ${firstResult.id} - ${firstResult.title}`);
|
||||||
|
return firstResult;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Converter] Error searching by metadata:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Spotify track to Deezer track ID
|
||||||
|
* Uses ISRC matching first, falls back to metadata search
|
||||||
|
*/
|
||||||
|
export async function convertSpotifyTrackToDeezer(
|
||||||
|
spotifyTrack: SpotifyTrackInput
|
||||||
|
): Promise<DeezerMatchResult> {
|
||||||
|
console.log(`[Converter] Converting Spotify track: ${spotifyTrack.name} by ${spotifyTrack.artists.join(', ')}`);
|
||||||
|
|
||||||
|
// Try ISRC matching first (most reliable)
|
||||||
|
if (spotifyTrack.isrc) {
|
||||||
|
const deezerTrack = await searchDeezerByISRC(spotifyTrack.isrc);
|
||||||
|
if (deezerTrack) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deezerTrack,
|
||||||
|
matchMethod: 'isrc'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
console.log(`[Converter] ISRC match failed for: ${spotifyTrack.isrc}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to metadata search
|
||||||
|
const artist = spotifyTrack.artists[0] || 'Unknown';
|
||||||
|
const deezerTrack = await searchDeezerByMetadata(
|
||||||
|
spotifyTrack.name,
|
||||||
|
artist,
|
||||||
|
spotifyTrack.duration_ms
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deezerTrack) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deezerTrack,
|
||||||
|
matchMethod: 'metadata'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match found
|
||||||
|
console.warn(`[Converter] Could not find Deezer match for: ${spotifyTrack.name} by ${artist}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
matchMethod: 'none',
|
||||||
|
error: 'No match found on Deezer'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert multiple Spotify tracks to Deezer track IDs
|
||||||
|
* Returns both successful conversions and failed tracks
|
||||||
|
*/
|
||||||
|
export async function convertSpotifyTracksBatch(
|
||||||
|
spotifyTracks: SpotifyTrackInput[]
|
||||||
|
): Promise<{
|
||||||
|
conversions: Array<{ spotifyId: string; deezerId: string; matchMethod: string }>;
|
||||||
|
failures: Array<{ spotifyId: string; name: string; artist: string; error: string }>;
|
||||||
|
}> {
|
||||||
|
const conversions: Array<{ spotifyId: string; deezerId: string; matchMethod: string }> = [];
|
||||||
|
const failures: Array<{ spotifyId: string; name: string; artist: string; error: string }> = [];
|
||||||
|
|
||||||
|
console.log(`[Converter] Converting ${spotifyTracks.length} Spotify tracks to Deezer...`);
|
||||||
|
|
||||||
|
for (const track of spotifyTracks) {
|
||||||
|
try {
|
||||||
|
const result = await convertSpotifyTrackToDeezer(track);
|
||||||
|
|
||||||
|
if (result.success && result.deezerTrack) {
|
||||||
|
conversions.push({
|
||||||
|
spotifyId: track.id,
|
||||||
|
deezerId: result.deezerTrack.id.toString(),
|
||||||
|
matchMethod: result.matchMethod || 'unknown'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
failures.push({
|
||||||
|
spotifyId: track.id,
|
||||||
|
name: track.name,
|
||||||
|
artist: track.artists[0] || 'Unknown',
|
||||||
|
error: result.error || 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Converter] Error converting track ${track.name}:`, error);
|
||||||
|
failures.push({
|
||||||
|
spotifyId: track.id,
|
||||||
|
name: track.name,
|
||||||
|
artist: track.artists[0] || 'Unknown',
|
||||||
|
error: error instanceof Error ? error.message : 'Conversion error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Converter] Conversion complete: ${conversions.length} successful, ${failures.length} failed`);
|
||||||
|
|
||||||
|
return { conversions, failures };
|
||||||
|
}
|
||||||
233
src/lib/services/spotify/playlistDownloader.ts
Normal file
233
src/lib/services/spotify/playlistDownloader.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* Download Spotify playlist - converts tracks to Deezer via ISRC, adds to queue, and creates m3u8 file
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addToQueue } from '$lib/stores/downloadQueue';
|
||||||
|
import { trackExists } from '$lib/services/deezer/downloader';
|
||||||
|
import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8';
|
||||||
|
import { generateTrackPath } from '$lib/services/deezer/paths';
|
||||||
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
|
import { deezerAPI } from '$lib/services/deezer';
|
||||||
|
import { setInfo, setSuccess, setWarning } from '$lib/stores/status';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { mkdir } from '@tauri-apps/plugin-fs';
|
||||||
|
import { convertSpotifyTrackToDeezer, type SpotifyTrackInput } from './converter';
|
||||||
|
import type { DeezerTrack } from '$lib/types/deezer';
|
||||||
|
|
||||||
|
export interface SpotifyPlaylistTrack {
|
||||||
|
id: number | string;
|
||||||
|
track_id: string;
|
||||||
|
name: string;
|
||||||
|
artist_name: string;
|
||||||
|
album_name: string;
|
||||||
|
duration_ms: number;
|
||||||
|
isrc?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a Spotify playlist by converting tracks to Deezer equivalents
|
||||||
|
* - Converts all tracks via ISRC matching
|
||||||
|
* - Adds converted tracks to the download queue (respects overwrite setting)
|
||||||
|
* - Creates an m3u8 playlist file with relative paths
|
||||||
|
*
|
||||||
|
* @param playlistName - Name of the playlist
|
||||||
|
* @param spotifyTracks - Array of Spotify track objects
|
||||||
|
* @param playlistsFolder - Path to playlists folder
|
||||||
|
* @param musicFolder - Path to music folder
|
||||||
|
* @returns Object with m3u8 path and statistics
|
||||||
|
*/
|
||||||
|
export async function downloadSpotifyPlaylist(
|
||||||
|
playlistName: string,
|
||||||
|
spotifyTracks: SpotifyPlaylistTrack[],
|
||||||
|
playlistsFolder: string,
|
||||||
|
musicFolder: string
|
||||||
|
): Promise<{
|
||||||
|
m3u8Path: string;
|
||||||
|
stats: {
|
||||||
|
total: number;
|
||||||
|
queued: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const appSettings = get(settings);
|
||||||
|
const authState = get(deezerAuth);
|
||||||
|
|
||||||
|
// Ensure Deezer is authenticated
|
||||||
|
if (!authState.loggedIn || !authState.arl) {
|
||||||
|
throw new Error('Deezer authentication required for downloads');
|
||||||
|
}
|
||||||
|
|
||||||
|
deezerAPI.setArl(authState.arl);
|
||||||
|
|
||||||
|
console.log(`[SpotifyPlaylistDownloader] Starting download for playlist: ${playlistName}`);
|
||||||
|
console.log(`[SpotifyPlaylistDownloader] Tracks: ${spotifyTracks.length}`);
|
||||||
|
|
||||||
|
// Ensure playlists folder exists
|
||||||
|
try {
|
||||||
|
await mkdir(playlistsFolder, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
// Folder might already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track statistics
|
||||||
|
let queuedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
// Track successful conversions for m3u8 generation
|
||||||
|
const successfulTracks: Array<{
|
||||||
|
deezerTrack: DeezerTrack;
|
||||||
|
spotifyTrack: SpotifyPlaylistTrack;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Convert and queue each track
|
||||||
|
for (const spotifyTrack of spotifyTracks) {
|
||||||
|
try {
|
||||||
|
// Convert Spotify track to Deezer
|
||||||
|
const conversionInput: SpotifyTrackInput = {
|
||||||
|
id: spotifyTrack.track_id,
|
||||||
|
name: spotifyTrack.name,
|
||||||
|
artists: [spotifyTrack.artist_name],
|
||||||
|
album: spotifyTrack.album_name,
|
||||||
|
duration_ms: spotifyTrack.duration_ms,
|
||||||
|
isrc: spotifyTrack.isrc
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversionResult = await convertSpotifyTrackToDeezer(conversionInput);
|
||||||
|
|
||||||
|
if (!conversionResult.success || !conversionResult.deezerTrack) {
|
||||||
|
console.warn(
|
||||||
|
`[SpotifyPlaylistDownloader] Failed to convert: ${spotifyTrack.name} - ${conversionResult.error}`
|
||||||
|
);
|
||||||
|
failedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deezerPublicTrack = conversionResult.deezerTrack;
|
||||||
|
|
||||||
|
// Fetch full track data from Deezer GW API (needed for download)
|
||||||
|
const deezerTrackId = deezerPublicTrack.id.toString();
|
||||||
|
const deezerFullTrack = await deezerAPI.getTrack(deezerTrackId);
|
||||||
|
|
||||||
|
if (!deezerFullTrack || !deezerFullTrack.SNG_ID) {
|
||||||
|
console.warn(`[SpotifyPlaylistDownloader] Could not fetch full Deezer track data for ID: ${deezerTrackId}`);
|
||||||
|
failedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build DeezerTrack object
|
||||||
|
const deezerTrack: DeezerTrack = {
|
||||||
|
id: parseInt(deezerFullTrack.SNG_ID, 10),
|
||||||
|
title: deezerFullTrack.SNG_TITLE,
|
||||||
|
artist: deezerFullTrack.ART_NAME,
|
||||||
|
artistId: parseInt(deezerFullTrack.ART_ID, 10),
|
||||||
|
artists: [deezerFullTrack.ART_NAME],
|
||||||
|
album: deezerFullTrack.ALB_TITLE,
|
||||||
|
albumId: parseInt(deezerFullTrack.ALB_ID, 10),
|
||||||
|
albumArtist: deezerFullTrack.ART_NAME,
|
||||||
|
albumArtistId: parseInt(deezerFullTrack.ART_ID, 10),
|
||||||
|
trackNumber:
|
||||||
|
typeof deezerFullTrack.TRACK_NUMBER === 'number'
|
||||||
|
? deezerFullTrack.TRACK_NUMBER
|
||||||
|
: parseInt(deezerFullTrack.TRACK_NUMBER, 10),
|
||||||
|
discNumber:
|
||||||
|
typeof deezerFullTrack.DISK_NUMBER === 'number'
|
||||||
|
? deezerFullTrack.DISK_NUMBER
|
||||||
|
: parseInt(deezerFullTrack.DISK_NUMBER, 10),
|
||||||
|
duration:
|
||||||
|
typeof deezerFullTrack.DURATION === 'number'
|
||||||
|
? deezerFullTrack.DURATION
|
||||||
|
: parseInt(deezerFullTrack.DURATION, 10),
|
||||||
|
explicit: deezerFullTrack.EXPLICIT_LYRICS === 1,
|
||||||
|
md5Origin: deezerFullTrack.MD5_ORIGIN,
|
||||||
|
mediaVersion: deezerFullTrack.MEDIA_VERSION,
|
||||||
|
trackToken: deezerFullTrack.TRACK_TOKEN
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if track already exists (if overwrite is disabled)
|
||||||
|
if (!appSettings.deezerOverwrite && appSettings.musicFolder) {
|
||||||
|
const exists = await trackExists(deezerTrack, appSettings.musicFolder, appSettings.deezerFormat);
|
||||||
|
if (exists) {
|
||||||
|
console.log(`[SpotifyPlaylistDownloader] Skipping "${deezerTrack.title}" - already exists`);
|
||||||
|
skippedCount++;
|
||||||
|
// Still add to successful tracks for m3u8 generation
|
||||||
|
successfulTracks.push({ deezerTrack, spotifyTrack });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue track for download
|
||||||
|
await addToQueue({
|
||||||
|
source: 'deezer',
|
||||||
|
type: 'track',
|
||||||
|
title: deezerTrack.title,
|
||||||
|
artist: deezerTrack.artist,
|
||||||
|
totalTracks: 1,
|
||||||
|
downloadObject: deezerTrack
|
||||||
|
});
|
||||||
|
|
||||||
|
queuedCount++;
|
||||||
|
successfulTracks.push({ deezerTrack, spotifyTrack });
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[SpotifyPlaylistDownloader] Queued: ${deezerTrack.title} (matched via ${conversionResult.matchMethod})`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SpotifyPlaylistDownloader] Error processing track ${spotifyTrack.name}:`, error);
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[SpotifyPlaylistDownloader] Queued ${queuedCount} tracks, skipped ${skippedCount}, failed ${failedCount}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show queue status message
|
||||||
|
if (queuedCount > 0) {
|
||||||
|
const parts = [`Queued ${queuedCount} track${queuedCount !== 1 ? 's' : ''}`];
|
||||||
|
if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
|
||||||
|
if (failedCount > 0) parts.push(`${failedCount} not found`);
|
||||||
|
setInfo(parts.join(', '));
|
||||||
|
} else if (skippedCount > 0) {
|
||||||
|
setWarning(`All ${skippedCount} tracks already exist`);
|
||||||
|
} else if (failedCount > 0) {
|
||||||
|
setWarning(`Could not find ${failedCount} tracks on Deezer`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate m3u8 file using Deezer track paths
|
||||||
|
const m3u8Tracks: M3U8Track[] = successfulTracks.map(({ deezerTrack, spotifyTrack }) => {
|
||||||
|
// Generate expected path for this Deezer track
|
||||||
|
const paths = generateTrackPath(deezerTrack, musicFolder, appSettings.deezerFormat, false);
|
||||||
|
const absolutePath = `${paths.filepath}/${paths.filename}`;
|
||||||
|
|
||||||
|
// Convert to relative path from playlists folder
|
||||||
|
const relativePath = makeRelativePath(absolutePath, 'Music');
|
||||||
|
|
||||||
|
return {
|
||||||
|
duration: deezerTrack.duration,
|
||||||
|
artist: deezerTrack.artist,
|
||||||
|
title: deezerTrack.title,
|
||||||
|
path: relativePath
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write m3u8 file
|
||||||
|
const m3u8Path = await writeM3U8(playlistName, m3u8Tracks, playlistsFolder);
|
||||||
|
|
||||||
|
console.log(`[SpotifyPlaylistDownloader] Playlist saved to: ${m3u8Path}`);
|
||||||
|
|
||||||
|
// Show success message for playlist creation
|
||||||
|
setSuccess(`Playlist created: ${playlistName} (${successfulTracks.length} tracks)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
m3u8Path,
|
||||||
|
stats: {
|
||||||
|
total: spotifyTracks.length,
|
||||||
|
queued: queuedCount,
|
||||||
|
skipped: skippedCount,
|
||||||
|
failed: failedCount
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export interface SpotifyAuthState {
|
|||||||
// User data
|
// User data
|
||||||
user: SpotifyUser | null;
|
user: SpotifyUser | null;
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
|
cacheTimestamp: number | null; // Unix timestamp in seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the store with spotify.json
|
// Initialize the store with spotify.json
|
||||||
@@ -36,7 +37,8 @@ const defaultState: SpotifyAuthState = {
|
|||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
user: null,
|
user: null,
|
||||||
loggedIn: false
|
loggedIn: false,
|
||||||
|
cacheTimestamp: null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a writable store for reactive UI updates
|
// Create a writable store for reactive UI updates
|
||||||
@@ -50,6 +52,7 @@ export async function loadSpotifyAuth(): Promise<void> {
|
|||||||
const refreshToken = await store.get<string>('refreshToken');
|
const refreshToken = await store.get<string>('refreshToken');
|
||||||
const expiresAt = await store.get<number>('expiresAt');
|
const expiresAt = await store.get<number>('expiresAt');
|
||||||
const user = await store.get<SpotifyUser>('user');
|
const user = await store.get<SpotifyUser>('user');
|
||||||
|
const cacheTimestamp = await store.get<number>('cacheTimestamp');
|
||||||
|
|
||||||
spotifyAuth.set({
|
spotifyAuth.set({
|
||||||
clientId: clientId ?? null,
|
clientId: clientId ?? null,
|
||||||
@@ -58,7 +61,8 @@ export async function loadSpotifyAuth(): Promise<void> {
|
|||||||
refreshToken: refreshToken ?? null,
|
refreshToken: refreshToken ?? null,
|
||||||
expiresAt: expiresAt ?? null,
|
expiresAt: expiresAt ?? null,
|
||||||
user: user ?? null,
|
user: user ?? null,
|
||||||
loggedIn: !!(accessToken && user)
|
loggedIn: !!(accessToken && user),
|
||||||
|
cacheTimestamp: cacheTimestamp ?? null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,5 +133,16 @@ export function isTokenExpired(expiresAt: number | null): boolean {
|
|||||||
return Date.now() >= (expiresAt - bufferTime);
|
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
|
// Initialize on module load
|
||||||
loadSpotifyAuth();
|
loadSpotifyAuth();
|
||||||
|
|||||||
@@ -239,7 +239,6 @@
|
|||||||
title: dbTrack.title,
|
title: dbTrack.title,
|
||||||
artist: dbTrack.artist_name,
|
artist: dbTrack.artist_name,
|
||||||
album: dbTrack.album_title || undefined,
|
album: dbTrack.album_title || undefined,
|
||||||
trackNumber: dbTrack.track_number || undefined,
|
|
||||||
duration: dbTrack.duration
|
duration: dbTrack.duration
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { spotifyAuth, loadSpotifyAuth, saveClientCredentials, saveTokens, saveUser, clearSpotifyAuth } from '$lib/stores/spotify';
|
import { goto } from '$app/navigation';
|
||||||
|
import { spotifyAuth, loadSpotifyAuth, saveClientCredentials, saveTokens, saveUser, clearSpotifyAuth, saveCacheTimestamp } from '$lib/stores/spotify';
|
||||||
import { spotifyAPI } from '$lib/services/spotify';
|
import { spotifyAPI } from '$lib/services/spotify';
|
||||||
import { start, onUrl } from '@fabianlars/tauri-plugin-oauth';
|
import { start, onUrl } from '@fabianlars/tauri-plugin-oauth';
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
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
|
// Fixed port for OAuth callback - user must register this in Spotify Dashboard
|
||||||
const OAUTH_PORT = 8228;
|
const OAUTH_PORT = 8228;
|
||||||
@@ -20,6 +35,24 @@
|
|||||||
let isWaitingForCallback = $state(false);
|
let isWaitingForCallback = $state(false);
|
||||||
let oauthUnlisten: (() => void) | null = $state(null);
|
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 () => {
|
onMount(async () => {
|
||||||
await loadSpotifyAuth();
|
await loadSpotifyAuth();
|
||||||
|
|
||||||
@@ -30,8 +63,134 @@
|
|||||||
if ($spotifyAuth.clientSecret) {
|
if ($spotifyAuth.clientSecret) {
|
||||||
clientSecretInput = $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() {
|
async function handleAuthorize() {
|
||||||
if (!clientIdInput || !clientSecretInput) {
|
if (!clientIdInput || !clientSecretInput) {
|
||||||
loginError = 'Please enter both Client ID and Client Secret';
|
loginError = 'Please enter both Client ID and Client Secret';
|
||||||
@@ -203,6 +362,9 @@
|
|||||||
// Fetch user info
|
// Fetch user info
|
||||||
const user = await spotifyAPI.getCurrentUser();
|
const user = await spotifyAPI.getCurrentUser();
|
||||||
await saveUser(user);
|
await saveUser(user);
|
||||||
|
|
||||||
|
// Load favorites after successful login
|
||||||
|
await loadFavorites();
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
localStorage.removeItem('spotify_code_verifier');
|
localStorage.removeItem('spotify_code_verifier');
|
||||||
@@ -220,6 +382,10 @@
|
|||||||
clientIdInput = '';
|
clientIdInput = '';
|
||||||
clientSecretInput = '';
|
clientSecretInput = '';
|
||||||
loginSuccess = '';
|
loginSuccess = '';
|
||||||
|
loginError = '';
|
||||||
|
playlists = [];
|
||||||
|
albums = [];
|
||||||
|
artists = [];
|
||||||
tracks = [];
|
tracks = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +393,9 @@
|
|||||||
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret) {
|
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshingUser = true;
|
||||||
|
userRefreshMessage = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Set credentials in API client
|
// Set credentials in API client
|
||||||
@@ -240,14 +409,33 @@
|
|||||||
// Fetch updated user info
|
// Fetch updated user info
|
||||||
const user = await spotifyAPI.getCurrentUser();
|
const user = await spotifyAPI.getCurrentUser();
|
||||||
await saveUser(user);
|
await saveUser(user);
|
||||||
|
|
||||||
userRefreshMessage = 'User info refreshed successfully!';
|
userRefreshMessage = 'User info refreshed successfully!';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
userRefreshMessage = '';
|
userRefreshMessage = '';
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
userRefreshMessage = 'Error refreshing user info: ' + (error instanceof Error ? error.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
refreshingUser = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
userRefreshMessage = '';
|
||||||
}, 3000);
|
}, 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>
|
</script>
|
||||||
|
|
||||||
@@ -334,51 +522,229 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if loading}
|
||||||
<p style="padding: 8px;">Loading favorites...</p>
|
<p style="padding: 8px;">Loading favorites...</p>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Authenticated View -->
|
<section class="favorites-content">
|
||||||
<section class="authenticated-content">
|
<!-- Tabs -->
|
||||||
<div class="window" style="max-width: 600px; margin: 8px;">
|
<!--
|
||||||
<div class="title-bar">
|
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||||
<div class="title-bar-text">Connected to Spotify</div>
|
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 tab-content" role="tabpanel">
|
||||||
<div class="window-body">
|
<div class="window-body">
|
||||||
{#if loginError}
|
{#if syncing}
|
||||||
<div class="error-message">
|
<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>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
{/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>
|
|
||||||
|
|
||||||
<fieldset style="margin-top: 16px;">
|
|
||||||
<legend>Actions</legend>
|
|
||||||
<div class="button-row">
|
|
||||||
<button onclick={handleRefreshUser}>Refresh User Info</button>
|
|
||||||
<button onclick={handleLogout}>Logout</button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div class="info-box">
|
|
||||||
<p><strong>Note:</strong> Spotify integration is for library sync only. This app does not support playback or downloads from Spotify.</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,11 +762,87 @@
|
|||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-section,
|
|
||||||
.login-section {
|
.login-section {
|
||||||
margin-bottom: 16px;
|
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 {
|
.window-body {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -499,16 +941,4 @@
|
|||||||
.instructions-content p {
|
.instructions-content p {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
|
||||||
|
|
||||||
.info-box {
|
|
||||||
margin-top: 16px;
|
|
||||||
padding: 8px;
|
|
||||||
background-color: var(--button-shadow, #2a2a2a);
|
|
||||||
border: 1px solid var(--button-highlight, #606060);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box p {
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|||||||
307
src/routes/services/spotify/playlists/[id]/+page.svelte
Normal file
307
src/routes/services/spotify/playlists/[id]/+page.svelte
Normal 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>
|
||||||
@@ -365,7 +365,7 @@
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Device Paths</legend>
|
<legend>Device Paths</legend>
|
||||||
<div class="field-row-stacked">
|
<div class="field-row-stacked">
|
||||||
<label>Device Music Path</label>
|
<div class="field-label">Device Music Path</div>
|
||||||
<div class="path-display">
|
<div class="path-display">
|
||||||
<code>{$deviceSyncSettings.musicPath || 'Not set'}</code>
|
<code>{$deviceSyncSettings.musicPath || 'Not set'}</code>
|
||||||
<button onclick={handleUpdateMusicPath}>Change...</button>
|
<button onclick={handleUpdateMusicPath}>Change...</button>
|
||||||
@@ -373,7 +373,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-row-stacked">
|
<div class="field-row-stacked">
|
||||||
<label>Device Playlists Path</label>
|
<div class="field-label">Device Playlists Path</div>
|
||||||
<div class="path-display">
|
<div class="path-display">
|
||||||
<code>{$deviceSyncSettings.playlistsPath || 'Not set'}</code>
|
<code>{$deviceSyncSettings.playlistsPath || 'Not set'}</code>
|
||||||
<button onclick={handleUpdatePlaylistsPath}>Change...</button>
|
<button onclick={handleUpdatePlaylistsPath}>Change...</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user