Compare commits

..

2 Commits

Author SHA1 Message Date
df4967dd55 fix(ui): update track number styling and playlist track mapping 2025-10-16 12:37:00 -04:00
1bffafad44 feat(spotify): library caching 2025-10-16 11:27:08 -04:00
10 changed files with 1572 additions and 72 deletions

View File

@@ -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())

View File

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

View File

@@ -0,0 +1,253 @@
<script lang="ts">
import type { Track } from '$lib/types/track';
import PageDecoration from '$lib/components/PageDecoration.svelte';
interface Props {
title: string;
subtitle?: string;
metadata?: string;
coverImageUrl?: string;
tracks: Track[];
selectedTrackIndex?: number | null;
onTrackClick?: (index: number) => void;
}
let {
title,
subtitle,
metadata,
coverImageUrl,
tracks,
selectedTrackIndex = null,
onTrackClick
}: Props = $props();
type ViewMode = 'tracks' | 'info';
let viewMode = $state<ViewMode>('tracks');
function handleTrackClick(index: number) {
if (onTrackClick) {
onTrackClick(index);
}
}
</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>
</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>
</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>
</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;
}
</style>

View File

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

View File

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

View File

@@ -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<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

View File

@@ -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<void> {
const refreshToken = await store.get<string>('refreshToken');
const expiresAt = await store.get<number>('expiresAt');
const user = await store.get<SpotifyUser>('user');
const cacheTimestamp = await store.get<number>('cacheTimestamp');
spotifyAuth.set({
clientId: clientId ?? null,
@@ -58,7 +61,8 @@ export async function loadSpotifyAuth(): Promise<void> {
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<void> {
await store.set('cacheTimestamp', timestamp);
await store.save();
spotifyAuth.update(s => ({
...s,
cacheTimestamp: timestamp
}));
}
// Initialize on module load
loadSpotifyAuth();

View File

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

View File

@@ -1,9 +1,24 @@
<script lang="ts">
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 { start, onUrl } from '@fabianlars/tauri-plugin-oauth';
import { openUrl } from '@tauri-apps/plugin-opener';
import {
getCachedPlaylists,
getCachedAlbums,
getCachedArtists,
getCachedTracks,
upsertPlaylists,
upsertAlbums,
upsertArtists,
upsertTracks,
type SpotifyPlaylist,
type SpotifyAlbum,
type SpotifyArtist,
type SpotifyTrack
} from '$lib/library/spotify-database';
// Fixed port for OAuth callback - user must register this in Spotify Dashboard
const OAUTH_PORT = 8228;
@@ -20,6 +35,24 @@
let isWaitingForCallback = $state(false);
let oauthUnlisten: (() => void) | null = $state(null);
// Data state
type ViewMode = 'playlists' | 'tracks' | 'artists' | 'albums' | 'info';
let viewMode = $state<ViewMode>('playlists');
let playlists = $state<SpotifyPlaylist[]>([]);
let albums = $state<SpotifyAlbum[]>([]);
let artists = $state<SpotifyArtist[]>([]);
let tracks = $state<SpotifyTrack[]>([]);
let loading = $state(true);
let syncing = $state(false);
let error = $state<string | null>(null);
let selectedIndex = $state<number | null>(null);
// User refresh state
let refreshingUser = $state(false);
let userRefreshMessage = $state('');
const CACHE_DURATION = 24 * 60 * 60; // 24 hours in seconds
onMount(async () => {
await loadSpotifyAuth();
@@ -30,8 +63,134 @@
if ($spotifyAuth.clientSecret) {
clientSecretInput = $spotifyAuth.clientSecret;
}
if ($spotifyAuth.loggedIn) {
await loadFavorites();
} else {
loading = false;
}
});
async function loadFavorites() {
loading = true;
error = null;
try {
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
spotifyAPI.setTokens(
$spotifyAuth.accessToken!,
$spotifyAuth.refreshToken!,
$spotifyAuth.expiresAt!
);
// Check if we need to refresh cache
const now = Math.floor(Date.now() / 1000);
const cacheAge = $spotifyAuth.cacheTimestamp ? now - $spotifyAuth.cacheTimestamp : Infinity;
const needsRefresh = cacheAge > CACHE_DURATION;
if (needsRefresh) {
await refreshFavorites();
} else {
// Load from cache
const [cachedPlaylists, cachedAlbums, cachedArtists, cachedTracks] = await Promise.all([
getCachedPlaylists(),
getCachedAlbums(),
getCachedArtists(),
getCachedTracks()
]);
playlists = cachedPlaylists;
albums = cachedAlbums;
artists = cachedArtists;
tracks = cachedTracks;
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
if (errorMessage === 'REFRESH_TOKEN_REVOKED') {
await clearSpotifyAuth();
error = 'Your Spotify session has expired. Please log in again.';
} else {
error = 'Error loading favorites: ' + errorMessage;
}
viewMode = 'info';
} finally {
loading = false;
}
}
async function refreshFavorites() {
if (!$spotifyAuth.accessToken || syncing) {
return;
}
syncing = true;
error = null;
try {
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
spotifyAPI.setTokens(
$spotifyAuth.accessToken,
$spotifyAuth.refreshToken!,
$spotifyAuth.expiresAt!
);
// Fetch all favorites from API
console.log('[Spotify] Fetching favorites from API...');
const [apiPlaylists, apiAlbums, apiArtists, apiTracks] = await Promise.all([
spotifyAPI.getAllUserPlaylists(),
spotifyAPI.getAllUserAlbums(),
spotifyAPI.getAllUserArtists(),
spotifyAPI.getAllUserTracks()
]);
console.log('[Spotify] Fetched from API:', {
playlists: apiPlaylists.length,
albums: apiAlbums.length,
artists: apiArtists.length,
tracks: apiTracks.length
});
// Update database cache
console.log('[Spotify] Updating database cache...');
await upsertPlaylists(apiPlaylists);
await upsertAlbums(apiAlbums);
await upsertArtists(apiArtists);
await upsertTracks(apiTracks);
// Update cache timestamp
const now = Math.floor(Date.now() / 1000);
await saveCacheTimestamp(now);
console.log('[Spotify] Reloading from cache...');
// Reload from cache
const [cachedPlaylists, cachedAlbums, cachedArtists, cachedTracks] = await Promise.all([
getCachedPlaylists(),
getCachedAlbums(),
getCachedArtists(),
getCachedTracks()
]);
playlists = cachedPlaylists;
albums = cachedAlbums;
artists = cachedArtists;
tracks = cachedTracks;
console.log('[Spotify] Refresh complete!');
} catch (e) {
console.error('Error refreshing favorites:', e);
const errorMessage = e instanceof Error ? e.message : String(e);
if (errorMessage === 'REFRESH_TOKEN_REVOKED') {
await clearSpotifyAuth();
error = 'Your Spotify session has expired. Please log in again.';
} else {
error = 'Error refreshing favorites: ' + errorMessage;
}
viewMode = 'info';
} finally {
syncing = false;
}
}
async function handleAuthorize() {
if (!clientIdInput || !clientSecretInput) {
loginError = 'Please enter both Client ID and Client Secret';
@@ -203,6 +362,9 @@
// Fetch user info
const user = await spotifyAPI.getCurrentUser();
await saveUser(user);
// Load favorites after successful login
await loadFavorites();
// Clean up
localStorage.removeItem('spotify_code_verifier');
@@ -220,6 +382,10 @@
clientIdInput = '';
clientSecretInput = '';
loginSuccess = '';
loginError = '';
playlists = [];
albums = [];
artists = [];
tracks = [];
}
@@ -227,6 +393,9 @@
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret) {
return;
}
refreshingUser = true;
userRefreshMessage = '';
try {
// Set credentials in API client
@@ -240,14 +409,33 @@
// Fetch updated user info
const user = await spotifyAPI.getCurrentUser();
await saveUser(user);
userRefreshMessage = 'User info refreshed successfully!';
setTimeout(() => {
setTimeout(() => {
userRefreshMessage = '';
}, 3000);
} catch (error) {
} catch (error) {
userRefreshMessage = 'Error refreshing user info: ' + (error instanceof Error ? error.message : 'Unknown error');
} finally {
refreshingUser = false;
setTimeout(() => {
userRefreshMessage = '';
}, 3000);
}
}
function handleItemClick(index: number) {
selectedIndex = index;
}
function handlePlaylistDoubleClick(playlistId: string) {
goto(`/services/spotify/playlists/${playlistId}`);
}
function formatDuration(ms: number): string {
const mins = Math.floor(ms / 60000);
const secs = Math.floor((ms % 60000) / 1000);
return `${mins}:${String(secs).padStart(2, '0')}`;
}
</script>
@@ -334,18 +522,187 @@
</div>
</details>
</div>
</section>
{:else if loading}
<p style="padding: 8px;">Loading favorites...</p>
{:else}
<!-- Authenticated View -->
<section class="authenticated-content">
<div class="window" style="max-width: 600px; margin: 8px;">
<div class="title-bar">
<div class="title-bar-text">Connected to Spotify</div>
{:else}
<section class="favorites-content">
<!-- Tabs -->
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
-->
<menu role="tablist">
<li role="tab" aria-selected={viewMode === 'playlists'}>
<button onclick={() => viewMode = 'playlists'}>Playlists</button>
</li>
<li role="tab" aria-selected={viewMode === 'tracks'}>
<button onclick={() => viewMode = 'tracks'}>Tracks</button>
</li>
<li role="tab" aria-selected={viewMode === 'artists'}>
<button onclick={() => viewMode = 'artists'}>Artists</button>
</li>
<li role="tab" aria-selected={viewMode === 'albums'}>
<button onclick={() => viewMode = 'albums'}>Albums</button>
</li>
<li role="tab" aria-selected={viewMode === 'info'}>
<button onclick={() => viewMode = 'info'}>Info</button>
</li>
</menu>
<!-- Tab Content -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if loginError}
<div class="error-message">
<div class="window-body">
{#if syncing}
<div class="sync-status">
<p>Refreshing favorites from Spotify...</p>
</div>
{:else if viewMode === 'playlists'}
<!-- Playlists View -->
<div class="tab-header">
<h4>Favorite Playlists</h4>
<button onclick={refreshFavorites} disabled={syncing}>
Refresh
</button>
</div>
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Playlist</th>
<th>Tracks</th>
</tr>
</thead>
<tbody>
<!-- Virtual Spotify Likes Playlist (only show if we have favorite tracks) -->
{#if tracks.length > 0}
<tr
class:highlighted={selectedIndex === -1}
class="favorite-tracks-row"
onclick={() => handleItemClick(-1)}
ondblclick={() => handlePlaylistDoubleClick('spotify-likes')}
>
<td>Spotify Likes</td>
<td>{tracks.length}</td>
</tr>
{/if}
<!-- User Playlists -->
{#each playlists as playlist, i}
<tr
class:highlighted={selectedIndex === i}
onclick={() => handleItemClick(i)}
ondblclick={() => handlePlaylistDoubleClick(playlist.id)}
>
<td>{playlist.name}</td>
<td>{playlist.track_count}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'tracks'}
<!-- Tracks View -->
<div class="tab-header">
<h4>Favorite Tracks</h4>
<button onclick={refreshFavorites} disabled={syncing}>
Refresh
</button>
</div>
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{#each tracks as track, i}
<tr
class:highlighted={selectedIndex === i}
onclick={() => handleItemClick(i)}
>
<td>{track.name}</td>
<td>{track.artist_name}</td>
<td>{track.album_name}</td>
<td class="duration">{formatDuration(track.duration_ms)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'artists'}
<!-- Artists View -->
<div class="tab-header">
<h4>Followed Artists</h4>
<button onclick={refreshFavorites} disabled={syncing}>
Refresh
</button>
</div>
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Artist</th>
<th>Followers</th>
</tr>
</thead>
<tbody>
{#each artists as artist, i}
<tr
class:highlighted={selectedIndex === i}
onclick={() => handleItemClick(i)}
>
<td>{artist.name}</td>
<td>{artist.followers.toLocaleString()}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'albums'}
<!-- Albums View -->
<div class="tab-header">
<h4>Saved Albums</h4>
<button onclick={refreshFavorites} disabled={syncing}>
Refresh
</button>
</div>
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Album</th>
<th>Artist</th>
<th>Year</th>
</tr>
</thead>
<tbody>
{#each albums as album, i}
<tr
class:highlighted={selectedIndex === i}
onclick={() => handleItemClick(i)}
>
<td>{album.name}</td>
<td>{album.artist_name}</td>
<td>{album.release_date ? new Date(album.release_date).getFullYear() : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'info'}
<!-- User Info View -->
<div class="user-container">
{#if error}
<fieldset>
<legend>Error</legend>
<div class="error-message">
{error}
</div>
</fieldset>
{/if}
@@ -368,17 +725,26 @@
<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">
<div class="button-row">
<button onclick={handleRefreshUser} disabled={refreshingUser}>
{refreshingUser ? 'Refreshing...' : 'Refresh User Info'}
</button>
<button onclick={refreshFavorites} disabled={syncing}>
{syncing ? 'Refreshing...' : 'Refresh Cache'}
</button>
<button onclick={handleLogout}>Logout</button>
</div>
</fieldset>
<div class="info-box">
</fieldset>
</div>
{/if}
</div>
</div>
@@ -396,11 +762,87 @@
h2 {
margin: 0;
}
.login-section,
.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 @@
.instructions-content p {
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;
}

View File

@@ -0,0 +1,240 @@
<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';
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);
// Convert Spotify tracks to Track type for CollectionView
let tracks = $derived<Track[]>(
playlistTracks.map((track, i) => ({
path: '',
filename: '',
format: 'unknown' as AudioFormat,
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;
}
</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}
onTrackClick={handleTrackClick}
/>
</div>
{/if}
<style>
.wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
</style>