From 1bffafad44281fc071c8c76c18b7af947a97d0c0 Mon Sep 17 00:00:00 2001 From: Markury Date: Thu, 16 Oct 2025 11:27:08 -0400 Subject: [PATCH] feat(spotify): library caching --- src-tauri/src/lib.rs | 69 +++ .../components/SpotifyCollectionView.svelte | 253 ++++++++ src/lib/library/spotify-database.ts | 343 +++++++++++ src/lib/services/spotify.ts | 167 +++++- src/lib/stores/spotify.ts | 19 +- src/routes/services/spotify/+page.svelte | 548 ++++++++++++++++-- .../spotify/playlists/[id]/+page.svelte | 240 ++++++++ 7 files changed, 1570 insertions(+), 69 deletions(-) create mode 100644 src/lib/components/SpotifyCollectionView.svelte create mode 100644 src/lib/library/spotify-database.ts create mode 100644 src/routes/services/spotify/playlists/[id]/+page.svelte 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 @@ + + + + + +
+ {#if coverImageUrl} + {title} cover + {:else} +
+ {/if} +
+

{title}

+ {#if subtitle} +

{subtitle}

+ {/if} + {#if metadata} + + {/if} +
+
+ +
+ + + +
  • + +
  • +
  • + +
  • +
    + + +
    +
    + {#if viewMode === 'tracks'} + +
    + + + + + + + + + + + + {#each tracks as track, i} + handleTrackClick(i)} + > + + + + + + + {/each} + +
    #TitleArtistAlbumDuration
    + {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} +
    +
    + {: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} - -
    -
    -
    -
    Connected to Spotify
    -
    +
    + + + +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
    + + +
    - {#if loginError} -
    - {loginError} + {#if syncing} +
    +

    Refreshing favorites from Spotify...

    +
    + {:else if viewMode === 'playlists'} + +
    +

    Favorite Playlists

    + +
    +
    + + + + + + + + + + {#if tracks.length > 0} + handleItemClick(-1)} + ondblclick={() => handlePlaylistDoubleClick('spotify-likes')} + > + + + + {/if} + + + {#each playlists as playlist, i} + handleItemClick(i)} + ondblclick={() => handlePlaylistDoubleClick(playlist.id)} + > + + + + {/each} + +
    PlaylistTracks
    Spotify Likes{tracks.length}
    {playlist.name}{playlist.track_count}
    +
    + {:else if viewMode === 'tracks'} + +
    +

    Favorite Tracks

    + +
    +
    + + + + + + + + + + + {#each tracks as track, i} + handleItemClick(i)} + > + + + + + + {/each} + +
    TitleArtistAlbumDuration
    {track.name}{track.artist_name}{track.album_name}{formatDuration(track.duration_ms)}
    +
    + {:else if viewMode === 'artists'} + +
    +

    Followed Artists

    + +
    +
    + + + + + + + + + {#each artists as artist, i} + handleItemClick(i)} + > + + + + {/each} + +
    ArtistFollowers
    {artist.name}{artist.followers.toLocaleString()}
    +
    + {:else if viewMode === 'albums'} + +
    +

    Saved Albums

    + +
    +
    + + + + + + + + + + {#each albums as album, i} + handleItemClick(i)} + > + + + + + {/each} + +
    AlbumArtistYear
    {album.name}{album.artist_name}{album.release_date ? new Date(album.release_date).getFullYear() : '—'}
    +
    + {: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 +
    + + + +
    +
    {/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 -
    - - -
    -
    - -
    -

    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} +
    +

    Loading playlist...

    +
    +{:else if error} +
    +
    +
    +
    Error
    +
    +
    +

    {error}

    +
    +
    +
    +{:else if playlist} +
    + +
    +{/if} + +