diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 31bd175..a4d6aab 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -299,6 +299,74 @@ pub fn run() {
kind: MigrationKind::Up,
}];
+ let spotify_migrations = vec![Migration {
+ version: 1,
+ description: "create_spotify_cache_tables",
+ sql: "
+ CREATE TABLE IF NOT EXISTS spotify_playlists (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ track_count INTEGER DEFAULT 0,
+ owner_name TEXT,
+ image_url TEXT,
+ cached_at INTEGER NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS spotify_albums (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ artist_name TEXT NOT NULL,
+ track_count INTEGER DEFAULT 0,
+ release_date TEXT,
+ image_url TEXT,
+ cached_at INTEGER NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS spotify_artists (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ followers INTEGER DEFAULT 0,
+ image_url TEXT,
+ cached_at INTEGER NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS spotify_tracks (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ artist_name TEXT NOT NULL,
+ album_name TEXT,
+ duration_ms INTEGER DEFAULT 0,
+ isrc TEXT,
+ album_image_url TEXT,
+ cached_at INTEGER NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS spotify_playlist_tracks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ playlist_id TEXT NOT NULL,
+ track_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ artist_name TEXT NOT NULL,
+ album_name TEXT,
+ duration_ms INTEGER DEFAULT 0,
+ track_number INTEGER,
+ isrc TEXT,
+ cached_at INTEGER NOT NULL,
+ UNIQUE(playlist_id, track_id)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_spotify_playlists_name ON spotify_playlists(name);
+ CREATE INDEX IF NOT EXISTS idx_spotify_albums_artist ON spotify_albums(artist_name);
+ CREATE INDEX IF NOT EXISTS idx_spotify_artists_name ON spotify_artists(name);
+ CREATE INDEX IF NOT EXISTS idx_spotify_tracks_name ON spotify_tracks(name);
+ CREATE INDEX IF NOT EXISTS idx_spotify_tracks_isrc ON spotify_tracks(isrc);
+ CREATE INDEX IF NOT EXISTS idx_spotify_playlist_tracks_playlist ON spotify_playlist_tracks(playlist_id);
+ CREATE INDEX IF NOT EXISTS idx_spotify_playlist_tracks_track ON spotify_playlist_tracks(track_id);
+ CREATE INDEX IF NOT EXISTS idx_spotify_playlist_tracks_isrc ON spotify_playlist_tracks(isrc);
+ ",
+ kind: MigrationKind::Up,
+ }];
+
tauri::Builder::default()
.plugin(tauri_plugin_oauth::init())
.plugin(tauri_plugin_os::init())
@@ -307,6 +375,7 @@ pub fn run() {
tauri_plugin_sql::Builder::new()
.add_migrations("sqlite:library.db", library_migrations)
.add_migrations("sqlite:deezer.db", deezer_migrations)
+ .add_migrations("sqlite:spotify.db", spotify_migrations)
.build(),
)
.plugin(tauri_plugin_http::init())
diff --git a/src/lib/components/SpotifyCollectionView.svelte b/src/lib/components/SpotifyCollectionView.svelte
new file mode 100644
index 0000000..9f47332
--- /dev/null
+++ b/src/lib/components/SpotifyCollectionView.svelte
@@ -0,0 +1,253 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ viewMode = 'tracks'}>Tracks
+
+
+ viewMode = 'info'}>Info
+
+
+
+
+
+
+ {#if viewMode === 'tracks'}
+
+
+
+
+
+ #
+ Title
+ Artist
+ Album
+ Duration
+
+
+
+ {#each tracks as track, i}
+ handleTrackClick(i)}
+ >
+
+ {track.metadata.trackNumber ?? i + 1}
+
+ {track.metadata.title ?? '—'}
+ {track.metadata.artist ?? '—'}
+ {track.metadata.album ?? '—'}
+
+ {#if track.metadata.duration}
+ {Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
+ {:else}
+ —
+ {/if}
+
+
+ {/each}
+
+
+
+ {:else if viewMode === 'info'}
+
+
+
+ Playlist Information
+
+ Title:
+ {title}
+
+ {#if subtitle}
+
+ Creator:
+ {subtitle}
+
+ {/if}
+
+ Tracks:
+ {tracks.length}
+
+
+
+ {/if}
+
+
+
+
+
diff --git a/src/lib/library/spotify-database.ts b/src/lib/library/spotify-database.ts
new file mode 100644
index 0000000..d4b3e7c
--- /dev/null
+++ b/src/lib/library/spotify-database.ts
@@ -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 {
+ if (!db) {
+ db = await Database.load('sqlite:spotify.db');
+ }
+ return db;
+}
+
+/**
+ * Close database connection (for cache clearing)
+ */
+export async function closeSpotifyDatabase(): Promise {
+ if (db) {
+ await db.close();
+ db = null;
+ }
+}
+
+/**
+ * Get cached playlists
+ */
+export async function getCachedPlaylists(): Promise {
+ const database = await initSpotifyDatabase();
+ const playlists = await database.select(
+ 'SELECT * FROM spotify_playlists ORDER BY name COLLATE NOCASE'
+ );
+ return playlists || [];
+}
+
+/**
+ * Get cached albums
+ */
+export async function getCachedAlbums(): Promise {
+ const database = await initSpotifyDatabase();
+ const albums = await database.select(
+ 'SELECT * FROM spotify_albums ORDER BY artist_name COLLATE NOCASE, name COLLATE NOCASE'
+ );
+ return albums || [];
+}
+
+/**
+ * Get cached artists
+ */
+export async function getCachedArtists(): Promise {
+ const database = await initSpotifyDatabase();
+ const artists = await database.select(
+ 'SELECT * FROM spotify_artists ORDER BY name COLLATE NOCASE'
+ );
+ return artists || [];
+}
+
+/**
+ * Get cached tracks
+ */
+export async function getCachedTracks(): Promise {
+ const database = await initSpotifyDatabase();
+ const tracks = await database.select(
+ 'SELECT * FROM spotify_tracks ORDER BY name COLLATE NOCASE'
+ );
+ return tracks || [];
+}
+
+/**
+ * Upsert playlists
+ */
+export async function upsertPlaylists(playlists: any[]): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const database = await initSpotifyDatabase();
+ const tracks = await database.select(
+ '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 {
+ const database = await initSpotifyDatabase();
+ const playlists = await database.select(
+ 'SELECT * FROM spotify_playlists WHERE id = $1',
+ [playlistId]
+ );
+ return playlists?.[0] || null;
+}
+
+/**
+ * Upsert playlist tracks
+ */
+export async function upsertPlaylistTracks(playlistId: string, tracks: any[]): Promise {
+ 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 {
+ 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;
+ }
+}
diff --git a/src/lib/services/spotify.ts b/src/lib/services/spotify.ts
index f250254..1f917bf 100644
--- a/src/lib/services/spotify.ts
+++ b/src/lib/services/spotify.ts
@@ -1,6 +1,6 @@
import { fetch } from '@tauri-apps/plugin-http';
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_TOKEN_URL = 'https://accounts.spotify.com/api/token';
@@ -142,22 +142,20 @@ export class SpotifyAPI {
* Refresh the access token using the refresh token
*/
async refreshAccessToken(): Promise<{ access_token: string; expires_in: number }> {
- if (!this.refreshToken || !this.clientId || !this.clientSecret) {
- throw new Error('Missing refresh token or client credentials');
+ if (!this.refreshToken || !this.clientId) {
+ throw new Error('Missing refresh token or client ID');
}
- const credentials = btoa(`${this.clientId}:${this.clientSecret}`);
-
const params = new URLSearchParams({
grant_type: 'refresh_token',
- refresh_token: this.refreshToken
+ refresh_token: this.refreshToken,
+ client_id: this.clientId
});
const response = await fetch(SPOTIFY_TOKEN_URL, {
method: 'POST',
headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- 'Authorization': `Basic ${credentials}`
+ 'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
});
@@ -165,6 +163,18 @@ export class SpotifyAPI {
if (!response.ok) {
const errorText = await response.text();
console.error('Token refresh error:', errorText);
+
+ try {
+ const errorData = JSON.parse(errorText);
+ if (errorData.error === 'invalid_grant') {
+ throw new Error('REFRESH_TOKEN_REVOKED');
+ }
+ } catch (e) {
+ if ((e as Error).message === 'REFRESH_TOKEN_REVOKED') {
+ throw e;
+ }
+ }
+
throw new Error(`Token refresh failed: ${response.statusText}`);
}
@@ -175,10 +185,14 @@ export class SpotifyAPI {
this.expiresAt = Date.now() + (data.expires_in * 1000);
// Note: Spotify may or may not return a new refresh token
+ const refreshToken = data.refresh_token || this.refreshToken;
if (data.refresh_token) {
this.refreshToken = data.refresh_token;
}
+ // Save refreshed tokens to store
+ await saveTokens(this.accessToken, refreshToken, data.expires_in);
+
return {
access_token: data.access_token,
expires_in: data.expires_in
@@ -254,6 +268,143 @@ export class SpotifyAPI {
const afterParam = after ? `&after=${after}` : '';
return this.apiCall(`/me/following?type=artist&limit=${limit}${afterParam}`);
}
+
+ /**
+ * Get all user playlists (handles pagination)
+ */
+ async getAllUserPlaylists(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const allTracks: any[] = [];
+ let offset = 0;
+ const limit = 100;
+
+ while (true) {
+ const response = await this.apiCall(`/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 {
+ return this.apiCall(`/playlists/${playlistId}`);
+ }
}
// Export singleton instance
diff --git a/src/lib/stores/spotify.ts b/src/lib/stores/spotify.ts
index e4b1992..405c47f 100644
--- a/src/lib/stores/spotify.ts
+++ b/src/lib/stores/spotify.ts
@@ -23,6 +23,7 @@ export interface SpotifyAuthState {
// User data
user: SpotifyUser | null;
loggedIn: boolean;
+ cacheTimestamp: number | null; // Unix timestamp in seconds
}
// Initialize the store with spotify.json
@@ -36,7 +37,8 @@ const defaultState: SpotifyAuthState = {
refreshToken: null,
expiresAt: null,
user: null,
- loggedIn: false
+ loggedIn: false,
+ cacheTimestamp: null
};
// Create a writable store for reactive UI updates
@@ -50,6 +52,7 @@ export async function loadSpotifyAuth(): Promise {
const refreshToken = await store.get('refreshToken');
const expiresAt = await store.get('expiresAt');
const user = await store.get('user');
+ const cacheTimestamp = await store.get('cacheTimestamp');
spotifyAuth.set({
clientId: clientId ?? null,
@@ -58,7 +61,8 @@ export async function loadSpotifyAuth(): Promise {
refreshToken: refreshToken ?? null,
expiresAt: expiresAt ?? 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);
}
+// Save cache timestamp
+export async function saveCacheTimestamp(timestamp: number): Promise {
+ await store.set('cacheTimestamp', timestamp);
+ await store.save();
+
+ spotifyAuth.update(s => ({
+ ...s,
+ cacheTimestamp: timestamp
+ }));
+}
+
// Initialize on module load
loadSpotifyAuth();
diff --git a/src/routes/services/spotify/+page.svelte b/src/routes/services/spotify/+page.svelte
index 6a9e09c..a22cc95 100644
--- a/src/routes/services/spotify/+page.svelte
+++ b/src/routes/services/spotify/+page.svelte
@@ -1,9 +1,24 @@
@@ -334,51 +522,229 @@
+ {:else if loading}
+ Loading favorites...
{:else}
-
-
-
-
+
+
+
+
+
+ viewMode = 'playlists'}>Playlists
+
+
+ viewMode = 'tracks'}>Tracks
+
+
+ viewMode = 'artists'}>Artists
+
+
+ viewMode = 'albums'}>Albums
+
+
+ viewMode = 'info'}>Info
+
+
+
+
+
- {#if loginError}
-
- {loginError}
+ {#if syncing}
+
+
Refreshing favorites from Spotify...
+
+ {:else if viewMode === 'playlists'}
+
+
+
+
+
+
+ Playlist
+ Tracks
+
+
+
+
+ {#if tracks.length > 0}
+ handleItemClick(-1)}
+ ondblclick={() => handlePlaylistDoubleClick('spotify-likes')}
+ >
+ Spotify Likes
+ {tracks.length}
+
+ {/if}
+
+
+ {#each playlists as playlist, i}
+ handleItemClick(i)}
+ ondblclick={() => handlePlaylistDoubleClick(playlist.id)}
+ >
+ {playlist.name}
+ {playlist.track_count}
+
+ {/each}
+
+
+
+ {:else if viewMode === 'tracks'}
+
+
+
+
+
+
+ Title
+ Artist
+ Album
+ Duration
+
+
+
+ {#each tracks as track, i}
+ handleItemClick(i)}
+ >
+ {track.name}
+ {track.artist_name}
+ {track.album_name}
+ {formatDuration(track.duration_ms)}
+
+ {/each}
+
+
+
+ {:else if viewMode === 'artists'}
+
+
+
+
+
+
+ Artist
+ Followers
+
+
+
+ {#each artists as artist, i}
+ handleItemClick(i)}
+ >
+ {artist.name}
+ {artist.followers.toLocaleString()}
+
+ {/each}
+
+
+
+ {:else if viewMode === 'albums'}
+
+
+
+
+
+
+ Album
+ Artist
+ Year
+
+
+
+ {#each albums as album, i}
+ handleItemClick(i)}
+ >
+ {album.name}
+ {album.artist_name}
+ {album.release_date ? new Date(album.release_date).getFullYear() : '—'}
+
+ {/each}
+
+
+
+ {:else if viewMode === 'info'}
+
+
+ {#if error}
+
+ Error
+
+ {error}
+
+
+ {/if}
+
+
+ User Information
+
+ Name:
+ {$spotifyAuth.user?.display_name || 'Unknown'}
+
+
+ Email:
+ {$spotifyAuth.user?.email || 'N/A'}
+
+
+ Country:
+ {$spotifyAuth.user?.country || 'N/A'}
+
+
+ Subscription:
+ {$spotifyAuth.user?.product ? $spotifyAuth.user.product.toUpperCase() : 'Unknown'}
+
+
+
+ {#if userRefreshMessage}
+
+ {userRefreshMessage}
+
+ {/if}
+
+
+ Actions
+
+
+ {refreshingUser ? 'Refreshing...' : 'Refresh User Info'}
+
+
+ {syncing ? 'Refreshing...' : 'Refresh Cache'}
+
+ Logout
+
+
{/if}
-
-
- User Information
-
- Name:
- {$spotifyAuth.user?.display_name || 'Unknown'}
-
-
- Email:
- {$spotifyAuth.user?.email || 'N/A'}
-
-
- Country:
- {$spotifyAuth.user?.country || 'N/A'}
-
-
- Subscription:
- {$spotifyAuth.user?.product ? $spotifyAuth.user.product.toUpperCase() : 'Unknown'}
-
-
-
-
- Actions
-
- Refresh User Info
- Logout
-
-
-
-
-
Note: Spotify integration is for library sync only. This app does not support playback or downloads from Spotify.
-
@@ -396,11 +762,87 @@
margin: 0;
}
- .login-section,
- .authenticated-content {
+ .login-section {
margin-bottom: 16px;
}
+ .favorites-content {
+ margin: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ }
+
+ .tab-content {
+ margin-top: -2px;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ }
+
+ .tab-content .window-body {
+ padding: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ }
+
+ .tab-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px;
+ border-bottom: 1px solid var(--button-shadow, #808080);
+ }
+
+ .tab-header h4 {
+ margin: 0;
+ }
+
+ .table-container {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0;
+ }
+
+ table {
+ width: 100%;
+ }
+
+ th {
+ text-align: left;
+ }
+
+ .duration {
+ font-family: monospace;
+ font-size: 0.9em;
+ text-align: center;
+ width: 80px;
+ }
+
+ .user-container {
+ padding: 16px;
+ }
+
+ .message-box {
+ padding: 8px;
+ margin: 8px 0;
+ background-color: var(--button-shadow, #2a2a2a);
+ border: 1px solid var(--button-highlight, #606060);
+ }
+
+ .sync-status {
+ padding: 16px 8px;
+ text-align: center;
+ }
+
+ .favorite-tracks-row {
+ font-weight: bold;
+ }
+
.window-body {
padding: 12px;
}
@@ -499,16 +941,4 @@
margin: 8px 0;
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;
- }
diff --git a/src/routes/services/spotify/playlists/[id]/+page.svelte b/src/routes/services/spotify/playlists/[id]/+page.svelte
new file mode 100644
index 0000000..d78671f
--- /dev/null
+++ b/src/routes/services/spotify/playlists/[id]/+page.svelte
@@ -0,0 +1,240 @@
+
+
+{#if loading}
+
+{:else if error}
+
+{:else if playlist}
+
+
+
+{/if}
+
+